diff --git a/AGENTS.md b/AGENTS.md index fea4074..3117760 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ - Explicit user decision 2026-09-25: every Finding Reader states within seconds what is wrong and what to do. Directly below the setting or rule heading, a `Problem` block (`Summary` for a passed result) with the outcome-colored marker and a separate `Next step` block with the orange `--next-step-accent` marker precede all explanatory text; both markers use `--marker-width`. Background (description, technical explanation, rationale) follows openly, references and documentation after it; work tracking and Additional Information stay collapsed at the end. - Scanner profiles declare their first-read fields in `presentation.firstRead`; React components never name scanner-specific fields. Use only retained source values: a missing field removes its line and nothing is summarized, rephrased or invented. Counts come from explicit source counts or linked result tables; a result row is red or green only where the source marks it (CIS-CAT criterion/evaluated rows with the finding outcome, `resultSheetRowStatusCells` markers). Split a long remediation only at a paragraph boundary and keep the rest complete behind `Full remediation`. An imported documentation link is offered only as a plain https URL with `noopener noreferrer`. - Explicit user decision 2026-09-25: every documented standard Windows code shown as text carries its meaning directly after it, in muted regular text: well-known SIDs, standardized domain RIDs (`S-1-5-21--500/512/...`; the domain part is never resolved), User Rights Assignment constants (`SeNetworkLogonRight` -> `Access this computer from the network`) and registry type codes (`4` -> `REG_DWORD`). The single list lives in `src/shared/windows-codes.ts` and is applied centrally by `PathText`; the original code stays unchanged and authoritative, unknown or customer-specific codes stay plain, and a meaning already stated next to the code is not repeated. -- Maester's native test result (ResultDetail) stays in Results only (2026-09-25 decision); its first read shows the source-marked row counts and the https documentation link. GPO findings use the GPO problem summary rules above. +- Explicit user decision 2026-09-28 supersedes the Results-only part of the 2026-09-25 Maester rule: the first prose paragraph of Maester's native test result (`ResultDetail`, or `Result` next to a separate `Status` column) is the `Problem` statement, verbatim, cut only where a heading or table begins. Its tables and the complete text stay in Results; source-marked row counts and the https documentation link remain. A bare status word is never a statement. GPO findings use the GPO problem summary rules above. - Explicit user correction 2026-09-25: when a scanner's first-read remediation explicitly introduces a PowerShell command, render that command in its own readable, contained block beneath the explanatory sentence. Keep the original command text complete and copyable, do not rewrite its parameters or split the remaining remediation before the original paragraph boundary, and retain the full source field unchanged. ## UI Pathfinder graph text and details @@ -108,7 +108,7 @@ ## UI Result DB filter consistency -- Explicit user decision 2026-09-25: Result DB API and JSONL retain the actionable per-tool finding, public rule ID when validated, result/severity, available scanner guidance, and the GPO comparison's concrete technical Ist-/Sollwert and baseline. Do not invent an Istwert absent from the source. Concrete person, account, tenant, customer and system identities remain outside this disclosure projection. Non-public system SIDs use stable result-local placeholders throughout nested policy values and baseline variants; public Windows SIDs remain readable. CIS-CAT control numbers and verified public policy guidance must not be mistaken for private IPs or accounts. Keep complete originals in internal analysis. +- 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. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 9e70d82..b0778b2 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4,6 +4,10 @@ Referenzerkennung 2026-09-26 (Phase 13D, erster Umfang umgesetzt und verifiziert Status: lebendes Projektdokument +2026-09-29 - Client-Info-Umgebungskacheln (Phase 7, Darstellung umgesetzt; visuelle Abnahme offen): Die sechs vorhandenen Kacheln bleiben bei Vollbildbreite in einer Rasterzeile, haben gleiche quadratische Geometrie und laufen bei schmaleren Fenstern innerhalb des Rasters horizontal statt nach unten umzubrechen. Jede Kurzansicht zeigt hoechstens vier quellengestuetzte Fakten, in schmalen Quadraten zwei; alle weiteren Werte und Quelltabellen bleiben ueber den jeweiligen Results-Button zugaenglich. Die Betriebs- und Scandaten wurden nicht veraendert. Acht fokussierte Tests einschliesslich synthetischem Results-Klickpfad, die Designpruefung, der Produktversionsvertrag und beide Produktionsbuilds bestanden. Risiko/Abnahmegrenze: Der lokale Browser zeigte sechs gleich grosse Quadrate in einer Zeile; bei 260px Breite verdeckten lange Fakten vor der Nachkorrektur teilweise die zweite Faktenzeile. Die daraufhin ergaenzte Containerregel ist gebaut und getestet, aber ein erneuter sichtbarer Dark-/Light-Vergleich bei normalem Vollbildzoom ist wegen stockender Client-Navigation noch ausstehend. Die bestehende Version 2.9.0 wird wegen dieser offenen UI-Abnahme und weiterer paralleler, noch nicht gemeinsam freigegebener Arbeitsstaende nicht fuer diese einzelne Aenderung erhoeht. Der Phasenstatus bleibt unveraendert. + +2026-09-28 - Phase 13 / Result DB ohne Umgebungs-Fingerabdruecke (2.9.0, umgesetzt und verifiziert): Eine Threat-Model-Pruefung des JSONL-Exports zeigte zwei Probleme. Erstens ein Vertragsverstoss: Die Offenlegungsprojektion kopierte alle Felder, sodass `reviewStatus`, `reviewDisposition` (inklusive akzeptierter Risiken) und `reviewPriority` in Result DB API und JSONL gelangten. Zweitens erlaubten exakte Anzahlen, Zeitangaben, GPO-Istwerte mit Principals, Umgebungsbeobachtungen (DC-Anzahl, Funktionsebenen, Scores) und konkrete Baseline-Releases, die Umgebung wiederzuerkennen und den Angriffsaufwand zu kalibrieren. Neue verbindliche Nutzeranforderung: Die Result DB muss datenschutzkonform sein und darf keine Angriffsanleitung bieten; Exporte koennen an lokale und externe LLMs sowie Dritte gehen. Diese Anforderung ersetzt ausdruecklich die Entscheidung vom 2026-09-25, Istwert, Scannerwerte und Baseline-Release vollstaendig offenzulegen (AGENTS.md aktualisiert). Umsetzung: Disclosure Policy 3.0.0 mit kompilergepruefter Feldklassifikation `RESULT_DB_FIELD_DISCLOSURE`; Review-Spalte und Review-Facetten aus der Result DB entfernt; GPO nur noch mit Setting-ID, Sollwert, Relation und Baseline-Familie, zusammengefassten identischen Baseline-Erwartungen und ohne Quellenanzahl; Anzahlen, Datum, Zeit und Dauer in Scannerprosa ausser CIS-CAT als `[count]`/`[date]`/`[time]`/`[duration]` bei erhaltenen oeffentlichen Kennungen; Zusatzbeobachtungs-Policy 2.0.0 nur mit binaeren Konfigurationszustaenden; JSONL-Schema 3.0.0 mit `generatedOn` (nur Datum), datiertem Dateinamen und Kennzeichnung `confidential-security-posture`. Work und interne Analyse behalten alle Originale; keine Import-, Schema-, Persistenz- oder Bestandsmutation und kein Reimport. Risiko/Grenze: Oeffentliche Zahlenschwellen in Nicht-CIS-Empfehlungstexten (etwa Passwortlaengen) werden ebenfalls zu `[count]`, weil statischer und umgebungsbezogener Scannertext ohne Katalogbeleg nicht sicher unterscheidbar ist; das LLM verliert zudem Istwert und Mengen. Die inhaltsfreie Live-Pruefung gegen den laufenden Backend-Stand 2.9.0 mit der kanonischen PostgreSQL (nur Zaehler und Wahrheitswerte, keine Inhalte) fand drei weitere Punkte, die behoben sind: GPO-Titel aus Registry-Pfaden (`Software\Policies`) wurden seit Policy 2.5.0 faelschlich als `DOMAIN\Konto` in Principal-Platzhalter verwandelt (10.367 Zeilen; Titel behalten jetzt wie die Regel-ID ihre technische Identitaet), dokumentierte Microsoft-Principals im Sollwert nutzen jetzt die zentrale Liste `src/shared/windows-codes.ts` samt `NT SERVICE`/`NT VIRTUAL MACHINE`/`Window Manager`/`Font Driver Host`, und kompakte Dauerangaben (`24h`, `30d`) werden ersetzt. Live-Ergebnis: 14.726 Result-DB-Zeilen ohne Review-, Istwert-, Quellenanzahl- oder Identitaetsfelder, keine freistehende Ziffer in Nicht-CIS-Scannerprosa, alle GPO-Baselines nur als Familie, keine Istwerte in Varianten, Umgebungszeilen nur boolesch, keine Review-Facette; der Einzelabruf per ID ueber das PostgreSQL-Repository (28 Zeilen, darunter 8 mit internem Reviewstatus) gibt keine zurueckgehaltenen Felder aus; die Oberflaeche zeigt 9 Spalten ohne Review-Spalte und -Filter und im GPO-Detail nur Sollwert, Relation und Baseline-Familie. Grenze: 6 Sollwerte enthalten je einen nicht gelisteten Principal (SID-Muster `S-1-5-99-*`), der fail-closed als Platzhalter bleibt. Der PostgreSQL-Integrationstest wurde nicht ausgefuehrt, weil er Migrationen und Loeschungen ausfuehrt und laut `docs/POSTGRESQL.md` nie auf die produktive Datenbank zeigen darf, waehrend AGENTS.md eine zweite ASSESSOR-Datenbank verbietet. Tests: synthetische Fingerabdruck- und Ziffernwaechter-Tests fuer Projektion und JSONL, Baseline-Familie, Zusammenfassung der Varianten, Zusatzbeobachtungen, lokales Repository, angepasste PostgreSQL-Integrationserwartung (nicht gegen die kanonische Datenbank ausgefuehrt) und die Nutzerinteraktion `Export JSONL` in der Result DB. Fokussierte Tests, Typpruefung, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild bestanden; die volle Suite zeigt nur die 7 bereits auf dem unveraenderten Stand d8f131d fehlschlagenden Tests in Client-Info-/GPO-/Timeslot-Dateien. Nachtrag 2026-09-28 (Disclosure Policy 3.1.0, gleicher unveroeffentlichter Stand 2.9.0): Die Nutzerpruefung des Dummy-Exports (Policy 3.0.0; Dummy ausdruecklich zur Inhaltspruefung freigegeben) bestaetigte die Schliessung der Lecks und fand Ueberschwaerzung. Behoben: `[count].` statt Schrittnummern, Hersteller-Schwellenwerte im Empfehlungsfeld (Titel und Beschreibung bleiben voll geschwaerzt), CIS-Nummern und `Rule`-Verweise als `[ip]` (IPv4 nur noch alleinstehend, nie Teil laengerer Punktfolgen oder dokumentierter Bereichsgrenzen) sowie oeffentliche Richtlinien-/Registry-Pfade, umgebrochene Pfadzeilen und dokumentierte Windows-Autoritaeten als `[account]` (Dummy vorher 1.443 laut Nutzerauswertung, jetzt 1); der Handling-Hinweis nennt die erhaltenen oeffentlichen Baseline-Erwartungen. Live ueber alle Clients (nur Zaehler): 0 `[ip]`, 1 `[account]` (Named Pipe `SQL\QUERY`, bewusst fail-closed), 0 zerstoerte Listenmarker, keine freistehende Ziffer in Nicht-CIS-Titeln/-Beschreibungen. Offen: oeffentliche Domains, URLs und Graph-Berechtigungsnamen (`Directory.ReadWrite.All`) werden weiterhin zu `[domain]`/`[url]`; die Versionsfelder bleiben als Verfahrens- und Kompatibilitaetsangabe erhalten. Nachtrag 2 (Disclosure Policy 3.2.0, weiterhin unveroeffentlichter Stand 2.9.0): Die zweite Nutzerpruefung bestaetigte, dass meine vorherigen Dummy-Zaehler ungefiltert waren (4.760 statt 579 Abweichungszeilen) und die Listenmarker-Korrektur nur Zeilenanfaenge erfasste. Behoben im Exportumfang `deviation:true`: `[count].` in Inline-Aufzaehlungen 172 -> 1 (verbleibend eine einzelne Nummer ohne Folge), `[url]` 263 -> 2 (Platzhalter-Host `IP`), `[domain]` 58 -> 1 (Drittanbieter-Domain in einem Codebeispiel), `[account]` 0; ausserdem Zielwerte `set to N` in Empfehlungen, `adminCount=1`, Abschnittsnummern `CISA N.`, `Microsoft 365` und die Leerzeichen-Bereinigung vor `.onmicrosoft.com`. Die inhaltsfreie Pruefung ueber alle Clients fand 15 verbliebene `onmicrosoft.com`-Nennungen, alle generischer Maester-Text MT.1074 (`the .onmicrosoft.com domain`), kein Tenant-Name; 0 E-Mail-Adressen, 0 `[ip]`, keine Review- oder Istwertfelder. Nachtrag 3: Eine selbst eingefuehrte Luecke ist behoben: Links wurden erst nach dem Einsetzen von `[id]` bewertet, sodass ein Portal-Link an `[id]` abbrach und der Rest (etwa `/resourceGroups/`) im Klartext blieb. Links werden jetzt zuerst und als Ganzes bewertet; GUIDs, Platzhalter und Azure-Ressourcenpfade machen einen Link nicht oeffentlich. Synthetischer Regressionstest vorhanden. Nachtrag 4 (Phase 11/13, Migration 024 nach verifiziertem Backup `before-result-db-materialization-20260928.dump`, 388 TOC-Eintraege, SHA-256 im Manifest): Auf verbindliche Nutzeranforderung wird die Result DB beim Erzeugen geprueft und gespeichert (WYSIWYG fuer UI, API, Einzelabruf und JSONL) und traegt pro Zeile ein inhaltsfreies Protokoll; die Ansicht zeigt den Pruefstand und `Open original in Work`. Ein Identitaetswaechter pro Client (1.3.0) entfernt die eigenen Namen des Clients; drei Fehlalarmquellen (Asset-Kategorien, `*.name`-Umgebungsfelder, Registry-Pfade aus GPO-Werten) wurden an Dummy und ueber inhaltsfreie Zaehler gefunden und behoben. Live: 14.726 Zeilen, 0 Zeilen mit gesperrten Feldern, Aufbau rund 11 s nach Start. Ebenfalls auf Nutzerentscheidung: Maester zeigt die Ergebnisaussage (`ResultDetail` bzw. `Result` neben `Status`) wieder wortgetreu als `Problem`, live an Dummy bestaetigt. Der PostgreSQL-Integrationstest wurde nicht ausgefuehrt (keine erlaubte Testdatenbank). Die volle Suite zeigt nur die 7 bereits auf d8f131d fehlschlagenden Tests. Nachtrag 5 (2026-09-29, Disclosure Policy 3.3.1, weiterhin unveroeffentlichter Stand 2.9.0): Eine externe Bewertung des Dummy-Exports (Policy 3.2.0) fand drei Punkte, die am Code bestaetigt und behoben sind. Erstens verriet die Protokollliste `withheldFields`, bei welchen Zeilen der Client Reviewstatus, -prioritaet, -verantwortliche oder Faelligkeit gepflegt hatte; die Review-Felder werden jetzt zurueckgehalten, ohne im Protokoll zu erscheinen. Zweitens wurde `score` je Finding offengelegt; Purple-Knight-Indikatorscores und berechnete PingCastle-Punkte skalieren mit den betroffenen Objekten und werden jetzt zurueckgehalten. Drittens Ueberschwaerzung: `Limited` gilt nicht mehr als Rechtsform (Rechtsformen nur noch in registrierter Schreibweise), Versionen direkt am Namen (`Tls1.2`, `v1.0`), `Windows 95`, `2012-R2` sowie Verweise `step N`, `Option N` und `Page N` bleiben, dokumentierte Microsoft-GUIDs aus dem oeffentlichen Katalog (ASR-Regeln, erstanbieterseitige Anwendungs-IDs, eingebaute Rollenvorlagen, gegen Microsoft Learn geprueft) bleiben, jede andere GUID bleibt `[id]`. Die gemeldete ID-Kollision `18.10.42.11.1.1` war kein Parserfehler: Import und Original tragen `18.10.42.11.1.1.1` und `.2`, aber die Offenlegung liess nur sechs Ebenen durch; CIS-Nummern behalten jetzt bis zu acht Ebenen. Bewusst unveraendert, weil verbindlich: Zahlen in Titeln und Beschreibungen bleiben geschwaerzt (etwa CISA-Verhaeltnis, MachineAccountQuota-Standard), der Trace bleibt Teil jeder Zeile und des JSONL, GPO-Vergleiche werden weiter einzeln gezaehlt. Live nach automatischem Neuaufbau (Dummy freigegeben): 579 Dummy-Abweichungszeilen mit 0 `[organization]`, 0 `[id]`, 0 doppelten CIS-IDs; ueber alle Clients 14.726 Zeilen, 0 mit `score`, 0 mit Review-Feldnamen. Synthetische Regressionstests fuer Projektion, Protokoll und die Nutzerinteraktion Zeilendetail/`Export JSONL` mit gespeicherten Zeilen; keine Schema-, Import- oder Originaldatenaenderung, kein Reimport. Nachtrag 6 (2026-09-29, Disclosure Policy 3.4.0, gleicher Stand 2.9.0): Auf Nutzerentscheidung behaelt das Empfehlungsfeld zusaetzlich dokumentierte Herstellerwerte (`set to N` innerhalb eines Satzes ohne weitere Zahl, `value is N`, `the number N`, `assign N`, `N as the Value`, `N for mode`, Attributfilter wie `admincount=1`, `zero (0)`, `N+`, `N methods` und `only N will be listed`); Titel und Beschreibungen bleiben voll geschwaerzt, beobachtete Anzahlen im Empfehlungsfeld bleiben `[count]`. Dummy: `[count]` im Empfehlungsfeld 15 -> 0 (18 vor der `Option N`-Korrektur) (Titel 17, Beschreibung 9 unveraendert). Inhaltsfreie Pruefung ueber alle Clients: 16 Regeln mit neu erhaltenen Werten, jede mit genau einem Wertesatz ueber alle Clients, in denen sie vorkommt; 5 Regeln kommen nur bei einem Nicht-Dummy-Client vor, alle mit oeffentlicher Regel-ID und Anweisungsform (`set ... to N`, Attributfilter), nicht inhaltlich geprueft. Die volle Suite zeigt neben den 7 bekannten Fehlschlaegen 3 weitere in `client-info-facts.test.tsx`, die aus parallelen, nicht von dieser Aenderung stammenden Client-Info-Anpassungen (`DashboardPanel.tsx`, `OperatingSystemInventory.tsx`, `ui-tokens.css`) folgen. + 2026-09-26 - Phase 7 / Doppelte Client-Info-Fakten entfernt (2.8.7, umgesetzt und verifiziert): Der zuvor erprobte Work-artige Dashboardumbau wurde auf ausdruecklichen Nutzerwunsch vollstaendig zurueckgenommen. Die anschliessend beauftragte kleine Bereinigung behaelt alle bestehenden Kacheln, Positionen, Donuts, Domainverbindungen, CSS-Regeln und Results-Popups. Domain, Forest, beide Functional Levels und DC OS stehen in Domain Information; NetBIOS, Schema, Erstellungsdatum und Entra-Identitaet in Directory; Bestandszahlen bleiben in Data. Directory und Inventory zeigen identische Uebersichtsfakten nicht nochmals; ihre Results behalten saemtliche Kategorienwerte und Originaltabellen. Verglichen werden nur dieselben benannten Fakten mit gleichem Anzeigewert, nie unterschiedliche Fakten allein wegen gleicher Werte. Abweichende oder nicht numerische Angaben bleiben erhalten; unbekannte Werte bleiben unbekannt. Entscheidung: Patchversion 2.8.7 fuer die rueckwaertskompatible Darstellungsbereinigung, keine Daten-, Import-, Scoring-, API- oder Persistenzaenderung. Verifikation: 52 fokussierte Tests einschliesslich befuellter/leerer Datenlage, nicht numerischer DC-Angabe, tatsaechlicher Directory-/Inventory-Results-Klickwege, Posture-Verbindungen und UI-/API-Versionsvertrag; beide Produktionsbuilds, Design-/Local-only- und Diffpruefung bestanden. Synthetische Light-/Dark-Browserpruefung bestaetigt 14px/16px Rollen, 20px Inset und vollstaendige Results. Die gebaute CSS-Datei ist unveraendert. Grenze: unterschiedliche Darstellungen desselben Quellwertes werden vorsichtshalber nicht als identisch unterdrueckt. Phasenstatus unveraendert. 2026-09-26 - Phase 6/7 / Scanner-Einstieg im Report, CIS-Abschnitte und Work-Zusammenfuehrung (2.8.6, umgesetzt und verifiziert): Purple-Knight-Scanlaeufe mit Umfangsnamen (`for Active Directory`, `for Entra ID`, kombiniert) loesen ueber deklarierte Profil-`runNames` wieder auf das Purple-Knight-Profil auf; zuvor fielen sie in gemischtem Work auf generic zurueck (kein Problem/Next step, generische Status-, Facetten-, Result-Linker- und Graphlogik). Maester ohne Remediation-/HelpUrl-Feld zeigt den nativen Description-Abschnitt `#### Remediation action:` deklarativ (`firstRead.nextStepSection`) als Next step; der Text davor bleibt Hintergrund, jeder Text erscheint einmal. Eingefuehrte PowerShell-Befehle werden auch nach `script ...:` und bei Beginn mit Variablenzuweisung als Befehlsblock abgesetzt; exportbedingt flachgeklopfte Befehle erhalten nur in der Anzeige Zeilen an eindeutigen Grenzen (Anweisung nach schliessender Klammer, Pipeline), ausschliesslich Leerraum aendert sich. Purple-Knight-`Likelihood of compromise` endet vor dem angehaengten nativen `References`-Abschnitt (`firstRead.impactReferenceMarker`); die Referenztitel erscheinen an der Referenzposition, verlorene Absatzumbrueche werden angezeigt. CIS-Abschnittsnamen stammen ausschliesslich aus der gespeicherten Section-Score-Tabelle des eigenen Scanlaufs: `CIS Category` zeigt Nummer und Namen in numerischer Ordnung; Work-Ueberschriften ersetzen nach Nutzerkorrektur die fuehrende Regelnummer durch den Titel des umgebenden Abschnitts, die Nummer bleibt Source Rule ID, Plaketten zeigen Nummer plus Tooltip; widerspruechliche oder fehlende Titel lassen die Nummer stehen. Ursache fuer im Dummy nicht sichtbare Titel und Referenzen: Die Zusammenfuehrung von All Records mit GPO-Ergebnissen kopierte eine manuelle Feldliste. `withRebuiltLayout` uebernimmt jetzt alle Datensatzmetadaten und ersetzt nur Layoutfelder (id, index, title, fields, filled, meta, sourceFile, sourceSheet, highlighted); ein Schutztest mit synthetischem unbekanntem Zusatzfeld sichert kuenftige Metadaten. Dummy ueber den realen Work-Ladeweg (nur Zaehlungen): 411/411 CIS-Datensaetze mit Titeln und Referenzen, 160/160 benannte CIS-Kategorien, 92/92 getrennte Purple-Knight-Referenzabschnitte. Entscheidung: Patchversion 2.8.6 fuer rueckwaertskompatible Korrekturen und Verfeinerungen ueber gespeicherte Fakten; kein Reimport, keine Schema-, Import-, Scoring- oder Review-Aenderung. Verifikation: synthetische Regressionen fuer die tatsaechlichen Pfade (gemischtes Work mit und ohne GPO, Reader, Facetten, Kacheln, Zusammenfuehrung), Versionsvertragstests, beide Produktionsbuilds; Gesamtsuite 1.608 bestanden, 14 uebersprungen, dieselben sechs vorbestehenden fachfremden Fehler in GPO-/Security-Domain-/Result-DB-/Dashboard-Tests. Risiken/Grenzen: Security-Domain-Zuordnung bleibt stichwortbasiert (gesonderte Entscheidung offen); Pathfinder und Graphansichten zeigen CIS-Titel weiterhin mit Nummer; Purple-Knight-Referenztitel ohne Trennzeichen bleiben ein Textblock. Phasenstatus unveraendert. diff --git a/docs/POSTGRESQL.md b/docs/POSTGRESQL.md index cfcfeb3..47f776e 100644 --- a/docs/POSTGRESQL.md +++ b/docs/POSTGRESQL.md @@ -77,6 +77,13 @@ ein Rollback ohne Teilimport. Migration `002_import_fingerprints.sql` ergänzt `assessor_import_fingerprints`. Der SHA-256-Wert ist Primärschlüssel; gespeichert werden außerdem Dateiname, Größe, letzter Dateiänderungszeitpunkt, erster und letzter Importzeitpunkt sowie ein Wiederholungszähler. Der Abschluss eines bestätigten Batchimports verwendet `commitCanonicalImport`. Der PostgreSQL-Adapter schreibt neues Projekt, Assessment, Scan-Lauf, kanonische Importdaten und Fingerprints auf derselben Poolverbindung innerhalb derselben Transaktion. Die Repository-Revision wird dabei genau einmal erhöht. Ein Validierungs-, Auswahl- oder Datenbankfehler erzeugt ein vollständiges Rollback. +## Abgeleitete Result DB + +`assessor_result_db_rows` und `assessor_result_db_state` (Migration 024) sind abgeleitete Daten: Das +Backend baut sie unter einer Advisory-Sperre aus den kanonischen Tabellen neu auf, sobald sich die +Repository-Revision oder eine Policy-/Waechterversion aendert. Sie werden nie von Hand bearbeitet und +zaehlen die Repository-Revision nicht hoch. + ## Backup Vor Migrationen, Updates und größeren Löschvorgängen ist ein logisches Backup diff --git a/docs/RESULT_CONTRACT.md b/docs/RESULT_CONTRACT.md index 9a926b5..4550ebb 100644 --- a/docs/RESULT_CONTRACT.md +++ b/docs/RESULT_CONTRACT.md @@ -486,6 +486,63 @@ und Artefaktidentitaeten sowie Zeitpunkte. Manifest-Scope und Filter werden nur als Boolesche beziehungsweise Mengenangaben ausgegeben; Suchtext, Assetfilter und konkrete Scope-IDs werden nie serialisiert. +Disclosure Policy 2.6.0 (2026-09-28) schliesst eine Luecke zur obigen Grenze: +`reviewStatus`, `reviewDisposition` (einschliesslich akzeptierter Risiken) und +`reviewPriority` gelangten zuvor in Result DB API und JSONL. Die Projektion gibt +jetzt nur noch Felder aus, die in `RESULT_DB_FIELD_DISCLOSURE` ausdruecklich als +`disclose` klassifiziert sind; ein neues Projektionsfeld verhindert die +Kompilierung, bis es klassifiziert ist, und zur Laufzeit hinzugefuegte Felder +fallen weg. Review-Status und -Disposition sind deshalb keine Result-DB-Filter +mehr; Work und die interne Analyse behalten den Review unveraendert. JSONL-Schema +2.1.0 ergaenzt im Manifest `handling` mit der Klassifikation +`confidential-security-posture`: Die Result DB ist anonymisiert, beschreibt aber +weiterhin ausnutzbare Schwaechen der bewerteten Umgebung und ist bei Weitergabe +an externe LLM-Anbieter oder Dritte entsprechend zu behandeln. + +Disclosure Policy 3.0.0 und JSONL-Schema 3.0.0 (2026-09-28, verbindliche +Nutzeranforderung, ersetzt die Entscheidung vom 2026-09-25 zum vollstaendigen +Ist-/Sollwert): Die Result DB ist datenschutzkonform und keine Angriffsanleitung. +Sie enthaelt keine Werte, die die bewertete Umgebung wiedererkennbar machen oder +den Angriffsaufwand kalibrieren. Der GPO-Istwert samt Principals, die Anzahl der +GPO-Quellen und konkrete Baseline-Releases entfallen; es bleiben technische +Setting-ID, Sollwert, Comparator-Relation und die Baseline-Familie. Identische +Baseline-Erwartungen werden zusammengefasst. In Scannerprosa ausser CIS-CAT +werden exakte Anzahlen, Datums-, Zeit- und Dauerangaben zu `[count]`, `[date]`, +`[time]` und `[duration]`; oeffentliche technische Kennungen (Versionen, CVE/KB, +Regel- und ATT&CK-IDs, Produktnamen, Ports, Event-IDs, oeffentliche SIDs) bleiben. +CIS-CAT-Prosa ist Benchmarktext und behaelt ihre oeffentlichen Empfehlungswerte. +Die Zusatzbeobachtungs-Policy 2.0.0 laesst nur noch binaere Konfigurationszustaende +zu; Anzahlen, Scores, Funktionsebenen und Versionen bleiben intern. Das JSONL-Manifest +enthaelt statt `generatedAt` nur `generatedOn` (Kalendertag), der Dateiname nur das +Datum. Work und interne Analyse behalten alle Originalwerte. + +Disclosure Policy 3.1.0 (2026-09-28, Nutzerkorrektur nach Pruefung des Dummy-Exports) +begrenzt die Schwaerzung auf echte Umgebungswerte: Nummerierte Schritte bleiben; nur das +Empfehlungsfeld behaelt Hersteller-Schwellenwerte (`at least 8 characters`, `more than 180 days`, +`15 or more`), Titel und Beschreibung bleiben vollstaendig geschwaerzt. Gruppenrichtlinien- und +Registry-Pfade sowie dokumentierte Windows-Autoritaeten (`NT SERVICE`, `RESTRICTED SERVICES` u. a.) +gelten nicht als Konten. `[ip]` betrifft nur alleinstehende IPv4-Adressen, nie CIS-Regelnummern, +`Rule`-Verweise oder dokumentierte Bereichsgrenzen. Der Handling-Hinweis nennt jetzt ausdruecklich, +dass oeffentliche Baseline-Erwartungen, Herstellerhinweise und Schwellenwerte erhalten bleiben. + +Disclosure Policy 3.2.0 (2026-09-28, Nutzerkorrektur nach Pruefung des gefilterten Dummy-Exports) +erhaelt Inline-Aufzaehlungen (Folge ab 1 mit mindestens zwei Punkten, eine wiederholte oder +uebersprungene Quellnummer toleriert), dokumentierte Attributflags (`adminCount=1`) und im +Empfehlungsfeld Zielwerte (`set to 1`). Links und Hostnamen von Hersteller- und Standard- +dokumentation bleiben, solange sie weder `@`, Tenant-Merkmale (`onmicrosoft`, `sharepoint`), +private Literale noch weitere nicht oeffentliche Hosts enthalten. Tenant-Hosts und alle +uebrigen Hosts bleiben geschwaerzt. Dateinamen, ScubaGear-Kennungen, Graph-Berechtigungen, +Code-Namespaces und -Bezeichner sowie Satzverbindungen ohne Leerzeichen gelten nicht als Domain. + +Gespeicherte Result DB (2026-09-28, verbindliche Nutzeranforderung, Migration 024): Die Zeilen werden beim +Erzeugen offengelegt, geprueft und in `assessor_result_db_rows` gespeichert, mit Statussatz +`assessor_result_db_state` (Revision, Policy- und Waechterversion, Zeilen, Ersetzungen, gesperrte Zeilen). +Der Aufbau laeuft nach dem Start und nach jeder gespeicherten Aenderung; Lesezugriffe warten auf einen +aktuellen Stand. Jede Zeile traegt `disclosureTrace` (Feld, Regel, Anzahl, zurueckgehaltene Felder) ohne +Originalwerte. Der Identitaetswaechter 1.3.0 entfernt die eigenen Namen des Clients aus allen Zeilen und +sperrt ein Feld, wenn ein Name in anderer Schreibweise ueberlebt. `GET /api/v1/results/status` liefert den +Pruefstand; die Oberflaeche zeigt ihn und im Zeilendetail das Protokoll mit `Open original in Work`. + ### Asset-/Kantenschicht: Struktur ohne Producer `CANONICAL_ASSET`, `ASSET_EDGE`, `ASSET_PROPERTY` und `EDGE_COVERAGE` wurden diff --git a/package-lock.json b/package-lock.json index 37b8286..0c2ff74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "assessor", - "version": "2.8.7", + "version": "2.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "assessor", - "version": "2.8.7", + "version": "2.9.0", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "jsdom": "^29.1.1", diff --git a/package.json b/package.json index 2b1705e..7ccec13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assessor", - "version": "2.8.7", + "version": "2.9.0", "private": true, "type": "module", "scripts": { diff --git a/server/assessor-http-server.ts b/server/assessor-http-server.ts index d37ea51..44b1475 100644 --- a/server/assessor-http-server.ts +++ b/server/assessor-http-server.ts @@ -987,6 +987,9 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se if (segments.length === 1 && segments[0] === 'results' && method === 'GET') { status = 200; sendJson(response, status, await repository.queryUnifiedResults(unifiedQueryFrom(url)), requestId); return; } + if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'status' && method === 'GET') { + status = 200; sendJson(response, status, await repository.getResultDbStatus(), requestId); return; + } if (segments.length === 2 && segments[0] === 'results' && segments[1] === 'facets' && method === 'GET') { status = 200; sendJson(response, status, await repository.queryUnifiedResultFacets(unifiedQueryFrom(url)), requestId); return; } diff --git a/server/postgres/migration-catalog.ts b/server/postgres/migration-catalog.ts index 7a014bd..75009f2 100644 --- a/server/postgres/migration-catalog.ts +++ b/server/postgres/migration-catalog.ts @@ -19,6 +19,7 @@ import scannerEnvironmentContext from './migrations/018_scanner_environment_cont import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw'; import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw'; import referenceRecognition from './migrations/023_reference_recognition.sql?raw'; +import resultDbMaterialization from './migrations/024_result_db_materialization.sql?raw'; export interface DatabaseMigration { version: string; @@ -47,5 +48,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ { version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }, { version: '019', name: 'native-policy-targets', sql: nativePolicyTargets }, { version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation }, - { version: '023', name: 'reference-recognition', sql: referenceRecognition } + { version: '023', name: 'reference-recognition', sql: referenceRecognition }, + { version: '024', name: 'result-db-materialization', sql: resultDbMaterialization } ]; diff --git a/server/postgres/migrations/024_result_db_materialization.sql b/server/postgres/migrations/024_result_db_materialization.sql new file mode 100644 index 0000000..65f817d --- /dev/null +++ b/server/postgres/migrations/024_result_db_materialization.sql @@ -0,0 +1,26 @@ +-- Materialized Result DB (user decision 2026-09-28): every row is disclosed, checked and stored when +-- its source data changes, so UI, API and JSONL export read exactly the same verified content. +-- Derived data only: rows are rebuilt from canonical tables and never edited by hand. +CREATE TABLE assessor_result_db_rows ( + result_projection_id text PRIMARY KEY CHECK (btrim(result_projection_id) <> ''), + ordinal integer NOT NULL CHECK (ordinal >= 0), + project_id text NOT NULL REFERENCES assessor_projects(id) ON DELETE CASCADE, + source_kind text NOT NULL CHECK (btrim(source_kind) <> ''), + row_data jsonb NOT NULL CHECK (jsonb_typeof(row_data) = 'object'), + guard_withheld boolean NOT NULL DEFAULT false, + source_revision bigint NOT NULL +); +CREATE INDEX assessor_result_db_rows_project_idx ON assessor_result_db_rows(project_id, ordinal); +CREATE INDEX assessor_result_db_rows_ordinal_idx ON assessor_result_db_rows(ordinal); + +-- One state record describes the stored Result DB as a whole. +CREATE TABLE assessor_result_db_state ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + source_revision bigint NOT NULL, + disclosure_policy_version text NOT NULL, + identity_guard_version text NOT NULL, + row_count integer NOT NULL CHECK (row_count >= 0), + redaction_count integer NOT NULL CHECK (redaction_count >= 0), + guard_withheld_row_count integer NOT NULL CHECK (guard_withheld_row_count >= 0), + materialized_at timestamptz NOT NULL +); diff --git a/server/postgres/postgres-assessor-repository.ts b/server/postgres/postgres-assessor-repository.ts index 9d1fd78..a9412e8 100644 --- a/server/postgres/postgres-assessor-repository.ts +++ b/server/postgres/postgres-assessor-repository.ts @@ -55,7 +55,12 @@ import { type UnifiedResultQuery, type UnifiedResultQueryResult } from '../../src/domain/unified-result'; -import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure'; +import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure'; +import { buildResultDbIdentityGuards } from '../../src/domain/result-db-identity-guard'; +import { isResultDbCurrent, materializeResultDb, type ResultDbMaterializationStatus } from '../../src/domain/result-db-materialization'; + +/** Advisory lock serializing Result DB materialization across backend processes. */ +const RESULT_DB_MATERIALIZATION_LOCK = 420_260_928; import { createPolicyConsistencyUnifiedResults } from '../../src/evaluation/policy-consistency-results'; import { deriveExposureState, deriveIsIoe, deriveResultType } from '../../src/domain/result-contract-mapping'; import { importArtifactValidationErrors, ingestionJobValidationErrors, processingRunValidationErrors } from '../../src/domain/ingestion-persistence'; @@ -394,7 +399,10 @@ export class PostgresAssessorRepository implements AssessorRepository { static async create(pool: Pool): Promise { const result = await pool.query('SELECT revision FROM assessor_repository_state WHERE singleton = true'); if (!result.rows[0]) throw new Error('assessor_repository_state fehlt; Datenbankmigrationen wurden nicht ausgeführt.'); - return new PostgresAssessorRepository(pool, Number(result.rows[0].revision)); + const repository = new PostgresAssessorRepository(pool, Number(result.rows[0].revision)); + // Build or refresh the stored Result DB right after start, not on the first read. + repository.scheduleResultDbRebuild(); + return repository; } get revision(): number { @@ -426,6 +434,7 @@ export class PostgresAssessorRepository implements AssessorRepository { this.cachedRevision = databaseRevision; } await client.query('COMMIT'); + if (changed(result)) this.scheduleResultDbRebuild(); return result; } catch (error) { try { await client.query('ROLLBACK'); } catch { /* connection failure keeps original error */ } @@ -1225,20 +1234,152 @@ export class PostgresAssessorRepository implements AssessorRepository { } private async allUnifiedResults(query: UnifiedResultQuery = {}): Promise { - // Public tool identities are rebuilt by the Result DB disclosure policy. - // Do not prefilter on the untrusted source value before that mapping; the - // shared in-memory filter below applies the public value consistently. - const sourceQuery = query.audience === 'internal-analysis' ? query : { ...query, sourceTool: undefined }; + // 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 [scanner, environment, policyObservations, policyConsistency, policyEvaluations, policy] = await Promise.all([ - this.unifiedScannerResults(sourceQuery), - this.unifiedEnvironmentResults(sourceQuery), - this.unifiedPolicyObservationResults(sourceQuery), - this.unifiedPolicyConsistencyResults(sourceQuery), - this.unifiedPolicyEvaluationComparisonResults(sourceQuery), - this.unifiedPolicyResults(sourceQuery) + this.unifiedScannerResults(query), + this.unifiedEnvironmentResults(query), + this.unifiedPolicyObservationResults(query), + this.unifiedPolicyConsistencyResults(query), + this.unifiedPolicyEvaluationComparisonResults(query), + this.unifiedPolicyResults(query) ]); - const results = [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy]; - return query.audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection); + return [...scanner, ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy]; + } + + private async databaseRevision(client: Pick = this.pool): Promise { + const result = await client.query('SELECT revision FROM assessor_repository_state WHERE singleton = true'); + return Number(result.rows[0]?.revision ?? -1); + } + + private async storedResultDbState(client: Pick = this.pool): Promise { + const result = await client.query<{ + source_revision: string; disclosure_policy_version: string; identity_guard_version: string; row_count: number; + redaction_count: number; guard_withheld_row_count: number; materialized_at: Date; + } & QueryResultRow>('SELECT * FROM assessor_result_db_state WHERE singleton = true'); + const row = result.rows[0]; + if (!row) return undefined; + return { + state: 'current', sourceRevision: Number(row.source_revision), repositoryRevision: Number(row.source_revision), + disclosurePolicyVersion: row.disclosure_policy_version, identityGuardVersion: row.identity_guard_version, + materializedAt: new Date(row.materialized_at).toISOString(), rowCount: row.row_count, + redactionCount: row.redaction_count, guardWithheldRowCount: row.guard_withheld_row_count + }; + } + + private resultDbBuild?: Promise; + private resultDbRebuildTimer?: ReturnType; + + /** Called after every committed change: the Result DB is rebuilt shortly after, not on the next read. */ + private scheduleResultDbRebuild(): void { + if (this.resultDbRebuildTimer) clearTimeout(this.resultDbRebuildTimer); + this.resultDbRebuildTimer = setTimeout(() => { + this.resultDbRebuildTimer = undefined; + if ((this.pool as Pool & { ending?: boolean }).ending) return; + void this.ensureResultDb().catch(error => console.warn('[assessor] Result DB rebuild failed; reads will retry.', error)); + }, 1500); + this.resultDbRebuildTimer.unref?.(); + } + + /** Guarantees that the stored Result DB matches the current repository revision and policy versions. */ + private async ensureResultDb(): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + if (isResultDbCurrent(await this.storedResultDbState(), await this.databaseRevision())) return; + this.resultDbBuild ??= this.rebuildResultDb().finally(() => { this.resultDbBuild = undefined; }); + await this.resultDbBuild; + } + if (!isResultDbCurrent(await this.storedResultDbState(), await this.databaseRevision())) { + throw new Error('The Result DB could not be brought up to date because the data kept changing. Please retry.'); + } + } + + /** + * Discloses, checks and stores every Result DB row in one transaction. The build reads the complete, + * unfiltered source data of all clients, so every client's identity guard is complete. Derived data: + * this does not advance the repository revision. + */ + private async rebuildResultDb(): Promise { + const client = await this.pool.connect(); + let locked = false; + try { + await client.query('SELECT pg_advisory_lock($1)', [RESULT_DB_MATERIALIZATION_LOCK]); + locked = true; + const revision = await this.databaseRevision(client); + if (isResultDbCurrent(await this.storedResultDbState(client), revision)) return; + const query: UnifiedResultQuery = { audience: 'result-db' }; + const [scanner, environment, policyConsistency, policyEvaluations, policy, policyObservations, projects, observations] = await Promise.all([ + this.unifiedScannerResults(query), + this.unifiedEnvironmentResults(query), + this.unifiedPolicyConsistencyResults(query), + this.unifiedPolicyEvaluationComparisonResults(query), + this.unifiedPolicyResults(query), + this.currentPolicyObservationResults(), + this.pool.query>('SELECT data FROM assessor_projects'), + this.pool.query<{ project_id: string; observation: EnvironmentObservation } & QueryResultRow>(` + SELECT a.project_id, o.data AS observation + FROM assessor_environment_observations o + JOIN assessor_scan_runs r ON r.id = o.scan_run_id + JOIN assessor_assessments a ON a.id = r.assessment_id + `) + ]); + const sources = [...scanner, ...environment, ...policyConsistency, ...policyEvaluations, ...policy]; + const guards = buildResultDbIdentityGuards({ + projects: projects.rows.map(row => row.data), + environmentObservations: observations.rows.map(row => ({ projectId: row.project_id, observation: row.observation })), + internalResults: [...sources, ...policyObservations] + }); + const { rows, status } = materializeResultDb(sources, guards, revision, new Date().toISOString()); + await client.query('BEGIN'); + await client.query('DELETE FROM assessor_result_db_rows'); + for (let start = 0; start < rows.length; start += 500) { + const batch = rows.slice(start, start + 500).map((row, index) => ({ + result_projection_id: row.resultProjectionId, + ordinal: start + index, + project_id: row.projectId, + source_kind: row.sourceKind, + row_data: row, + guard_withheld: Boolean(row.disclosureTrace?.guardWithheldFields.length), + source_revision: revision + })); + await client.query(` + INSERT INTO assessor_result_db_rows (result_projection_id, ordinal, project_id, source_kind, row_data, guard_withheld, source_revision) + SELECT result_projection_id, ordinal, project_id, source_kind, row_data, guard_withheld, source_revision + FROM jsonb_to_recordset($1::jsonb) AS row(result_projection_id text, ordinal integer, project_id text, + source_kind text, row_data jsonb, guard_withheld boolean, source_revision bigint) + `, [JSON.stringify(batch)]); + } + await client.query(` + INSERT INTO assessor_result_db_state (singleton, source_revision, disclosure_policy_version, identity_guard_version, + row_count, redaction_count, guard_withheld_row_count, materialized_at) + VALUES (true, $1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (singleton) DO UPDATE SET source_revision = EXCLUDED.source_revision, + disclosure_policy_version = EXCLUDED.disclosure_policy_version, identity_guard_version = EXCLUDED.identity_guard_version, + row_count = EXCLUDED.row_count, redaction_count = EXCLUDED.redaction_count, + guard_withheld_row_count = EXCLUDED.guard_withheld_row_count, materialized_at = EXCLUDED.materialized_at + `, [revision, status.disclosurePolicyVersion, status.identityGuardVersion, status.rowCount, status.redactionCount, + status.guardWithheldRowCount, status.materializedAt]); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } finally { + if (locked) await client.query('SELECT pg_advisory_unlock($1)', [RESULT_DB_MATERIALIZATION_LOCK]).catch(() => undefined); + client.release(); + } + } + + private async storedResultDbRows(projectId?: string): Promise { + await this.ensureResultDb(); + const result = await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>(projectId + ? 'SELECT row_data FROM assessor_result_db_rows WHERE project_id = $1 ORDER BY ordinal' + : 'SELECT row_data FROM assessor_result_db_rows ORDER BY ordinal', projectId ? [projectId] : []); + return result.rows.map(row => row.row_data); + } + + async getResultDbStatus(): Promise { + const [state, revision] = await Promise.all([this.storedResultDbState(), this.databaseRevision()]); + if (!state) return { state: 'not-built', repositoryRevision: revision, rowCount: 0, redactionCount: 0, guardWithheldRowCount: 0 }; + return { ...state, repositoryRevision: revision, state: isResultDbCurrent(state, revision) ? 'current' : 'outdated' }; } async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise { @@ -1253,27 +1394,10 @@ export class PostgresAssessorRepository implements AssessorRepository { } async getUnifiedResultById(resultProjectionId: string): Promise { - const scannerPrefix = 'scanner-finding:'; - const policyPrefix = 'policy-evaluation:'; - const policyComparisonPrefix = 'policy-evaluation-comparison:'; - const policyObservationPrefix = 'policy-observation:'; - const policyConsistencyPrefix = 'policy-consistency:'; - const environmentPrefix = 'environment-observation:'; - const items = resultProjectionId.startsWith(scannerPrefix) - ? await this.unifiedScannerResults({}, resultProjectionId.slice(scannerPrefix.length)) - : resultProjectionId.startsWith(policyComparisonPrefix) - ? await this.unifiedPolicyEvaluationComparisonResults({}, resultProjectionId.slice(policyComparisonPrefix.length)) - : resultProjectionId.startsWith(policyPrefix) - ? await this.unifiedPolicyResults({}, resultProjectionId.slice(policyPrefix.length)) - : resultProjectionId.startsWith(policyObservationPrefix) - ? await this.unifiedPolicyObservationResults({}, resultProjectionId.slice(policyObservationPrefix.length)) - : resultProjectionId.startsWith(policyConsistencyPrefix) - ? await this.unifiedPolicyConsistencyResults({}) - : resultProjectionId.startsWith(environmentPrefix) - ? await this.unifiedEnvironmentResults({}, resultProjectionId.slice(environmentPrefix.length)) - : []; - const item = items.find(item => item.resultProjectionId === resultProjectionId); - return item ? removeResultDbScopeIdentifiers(discloseResultDbProjection(item)) : null; + await this.ensureResultDb(); + const result = await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>( + 'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1', [resultProjectionId]); + return result.rows[0] ? removeResultDbScopeIdentifiers(result.rows[0].row_data) : null; } async listResultCategories(query: UnifiedResultQuery = {}): Promise { diff --git a/src/api/assessor-api-client.ts b/src/api/assessor-api-client.ts index 3085e55..3b872d1 100644 --- a/src/api/assessor-api-client.ts +++ b/src/api/assessor-api-client.ts @@ -14,6 +14,7 @@ import type { ImportedDataset } from '../domain/dataset'; import type { ReportProjection } from '../domain/report-projection'; import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline'; import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow'; +import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization'; import { ASSESSOR_API_PREFIX, ASSESSOR_MUTATION_HEADER, @@ -227,6 +228,10 @@ export class AssessorApiClient { return this.request(unifiedQueryPath('/results/facets', query)); } + getResultDbStatus(): Promise { + return this.request('/results/status'); + } + getTimeslotResultMetrics(projectId: string, scanRunIds: string[], assessmentIds: string[]): Promise { return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/timeslot-result-metrics`, { scanRunIds, assessmentIds })); diff --git a/src/app/App.tsx b/src/app/App.tsx index 3917f89..0de911f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -5,6 +5,7 @@ import { DataHub } from '../features/project-import/DataHub'; import { DashboardPanel } from '../features/dashboard/DashboardPanel'; import { DbStructurePanel } from '../features/db-structure/DbStructurePanel'; import { ResultsBrowser } from '../features/results-browser/ResultsBrowser'; +import { workRecordIdsForResult } from '../features/results-browser/work-record-lookup'; import { SearchFilterBar, SearchTextControl, SheetAndSearchControls, WorkTimeslotControl } from '../features/search-filter/SearchFilterBar'; import { useFilteredRecords } from '../features/search-filter/use-filtered-records'; import { DatasetViewer } from '../features/dataset-viewer/DatasetViewer'; @@ -192,7 +193,9 @@ export function App() { )} {activeArea === 'dashboard' && } {activeArea === 'db-structure' && } - {activeArea === 'results' && } + {activeArea === 'results' && workRecordIdsForResult(dataset, row)} + onOpenInWork={recordIds => { app.setPathFindingIds(recordIds); openWork(); }}/>} diff --git a/src/domain/result-db-disclosure.ts b/src/domain/result-db-disclosure.ts index 19de6c9..8b00495 100644 --- a/src/domain/result-db-disclosure.ts +++ b/src/domain/result-db-disclosure.ts @@ -2,8 +2,11 @@ import catalog from '../profiles/public-search/catalog.json'; import type { JsonValue } from './canonical-model'; import type { PolicyBaselineVariantAssessment } from './policy-evaluation'; import type { UnifiedResultCategory, UnifiedResultProjection } from './unified-result'; +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 = '2.5.3'; +export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.4.0'; const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z'; @@ -34,6 +37,17 @@ function disclosureTool(item: UnifiedResultProjection): { id: string; label: str return safeTool(item.sourceTool); } +/** + * Documented Microsoft GUIDs (ASR rules, first-party application IDs, built-in role templates) are + * public guidance. Every other GUID can be a tenant, object or resource identifier and stays redacted. + */ +const publicGuids = new Set([...catalog.asrIds, ...catalog.microsoftIdentifiers].map(id => id.toLocaleLowerCase('en-US'))); +const guidPattern = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu; + +function isPublicGuid(value: string): boolean { + return publicGuids.has(value.toLocaleLowerCase('en-US')); +} + function exactCatalogId(values: readonly string[], value?: string): string | undefined { const folded = value?.trim().toLocaleLowerCase('en-US'); return folded && values.some(candidate => candidate.toLocaleLowerCase('en-US') === folded) @@ -54,7 +68,9 @@ function safePublicRuleId(item: UnifiedResultProjection): string | undefined { return id && /^SI\d{3,12}$/i.test(id) ? id : undefined; } if (scannerFamily !== 'cis-cat') return undefined; - const match = item.sourceRuleId?.match(/(?:^|[^0-9])([0-9]+(?:\.[0-9]+){1,5})(?:$|[^0-9])/); + // CIS recommendation numbers reach seven levels (18.10.42.11.1.1.1); a shorter cap truncates them + // into a sibling's number. A longer dotted run is not a recommendation number. + const match = item.sourceRuleId?.match(/(?:^|[^0-9.])([0-9]+(?:\.[0-9]+){1,7})(?:$|\.?[^0-9.])/); return match?.[1]; } @@ -62,6 +78,8 @@ const publicDomainNames = new Set([ 'microsoft.com', 'windows.com', 'office.com', 'microsoftonline.com', 'azure.com', 'cisecurity.org' ]); const publicAccountPrefixes = /^(?:BUILTIN|NT AUTHORITY)\\/i; +/** Built-in Windows authorities; names below them are documented Microsoft principals, not customer accounts. */ +const publicAccountAuthorities = /(?:^|[^\p{L}\p{N}])(?:BUILTIN|NT AUTHORITY|NT SERVICE|NT VIRTUAL MACHINE|RESTRICTED SERVICES|Window Manager|Font Driver Host)$/iu; const publicDomainRoles = new Set([ 'domain admins', 'domain users', 'domain guests', 'domain computers', 'domain controllers', 'cert publishers', 'schema admins', 'enterprise admins', 'group policy creator owners', @@ -107,9 +125,10 @@ class PolicyValueRedactor { redacted = redacted.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, match => this.principalToken(match)); if (redactAccountNames) { - redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, match => { - if (publicAccountPrefixes.test(match)) return match; + redacted = redacted.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$ -]+\b/gu, (match, offset: number, input: string) => { const separator = match.indexOf('\\'); + if (publicAccountPrefixes.test(match) + || publicAccountAuthorities.test(input.slice(0, offset) + match.slice(0, separator))) return match; const domain = match.slice(0, separator); const principal = match.slice(separator + 1); return publicDomainRoles.has(principal.toLocaleLowerCase('en-US')) @@ -120,7 +139,7 @@ class PolicyValueRedactor { redacted = redacted .replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[PATH]') .replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[PATH]') - .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[ID]') + .replace(guidPattern, match => isPublicGuid(match) ? match : '[ID]') .replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match => publicDomainNames.has(match.toLocaleLowerCase('en-US')) ? match : this.domainToken(match)); return redacted; @@ -153,11 +172,22 @@ function safePolicyNarrative(item: UnifiedResultProjection, redactor: PolicyValu return result || undefined; } -function safeBaselineLabel(item: UnifiedResultProjection, redactor: PolicyValueRedactor): string { - const label = item.policyBaselineLabel?.trim(); - if (!label) return 'Baseline recommendation'; - const publicPart = /\b(?:Microsoft|Windows|CIS|ANSSI|DISA|NCSC)\b.*$/iu.exec(label)?.[0]; - return redactor.text(publicPart ?? 'Baseline recommendation'); +const baselineFamilies: Array<[RegExp, string]> = [ + [/\bWindows\s+Server\b/iu, 'Microsoft Security Baseline - Windows Server'], + [/\bWindows\s+(?:1[01]|client)\b/iu, 'Microsoft Security Baseline - Windows Client'], + [/\b(?:Microsoft\s+365|Office)\b/iu, 'Microsoft Security Baseline - Microsoft 365 Apps'], + [/\bEdge\b/iu, 'Microsoft Security Baseline - Microsoft Edge'], + [/\bCIS\b/iu, 'CIS Benchmark'], + [/\bMicrosoft\b/iu, 'Microsoft Security Baseline'] +]; + +/** + * Only the baseline family is disclosed. Concrete releases (for example Windows 11 25H2 or + * Windows Server 2025) would reveal the operating-system generations of the environment. + */ +function safeBaselineLabel(item: UnifiedResultProjection): string { + const label = item.policyBaselineLabel?.trim() ?? ''; + return baselineFamilies.find(([pattern]) => pattern.test(label))?.[1] ?? 'Baseline recommendation'; } function safeBaselineVariantAssessment( @@ -165,32 +195,261 @@ function safeBaselineVariantAssessment( redactor: PolicyValueRedactor ): PolicyBaselineVariantAssessment | undefined { if (!assessment) return undefined; + // Customer values never leave internal analysis. Identical expectations are collapsed so the + // number of entries does not reveal how many baseline releases were compared. + const expectations = new Map(); + for (const variant of assessment.variants) { + const baselineValues = redactor.json(variant.baselineValues as unknown as JsonValue) as unknown as typeof variant.baselineValues; + const comparator = { + version: variant.comparator.version, + comparator: variant.comparator.comparator, + relation: variant.comparator.relation, + baseline: redactor.json(variant.comparator.baseline as unknown as JsonValue) as typeof variant.comparator.baseline, + diagnosticCodes: controlledDiagnosticCodes(variant.comparator.diagnosticCodes) ?? [] + }; + const key = JSON.stringify([baselineValues, comparator]); + if (!expectations.has(key)) { + expectations.set(key, { settingId: `baseline-expectation-${expectations.size + 1}`, baselineValues, comparator }); + } + } return { version: 1, scope: 'backup-configuration', decision: assessment.decision, - actualValues: redactor.json(assessment.actualValues as unknown as JsonValue) as unknown as PolicyBaselineVariantAssessment['actualValues'], - variants: assessment.variants.map((variant, index) => ({ - settingId: `baseline-variant-${index + 1}`, - baselineValues: redactor.json(variant.baselineValues as unknown as JsonValue) as unknown as typeof variant.baselineValues, - comparator: { - ...variant.comparator, - actual: redactor.json(variant.comparator.actual as unknown as JsonValue) as typeof variant.comparator.actual, - baseline: redactor.json(variant.comparator.baseline as unknown as JsonValue) as typeof variant.comparator.baseline, - diagnosticCodes: controlledDiagnosticCodes(variant.comparator.diagnosticCodes) ?? [] - } - })), + actualValues: [], + variants: [...expectations.values()], diagnosticCodes: controlledDiagnosticCodes(assessment.diagnosticCodes) ?? [] }; } const narrativeTools = new Set(['cis-cat', 'maester', 'pingcastle', 'purple-knight']); -const publicCisPolicyPathPairs = new Set([ - 'configuration\\administrative', 'templates\\system', 'sensors\\turn' + +/** + * Tokens whose digits are public knowledge rather than facts about the assessed environment: + * SIDs (already reduced to public or placeholder form), dotted versions and benchmark numbers, + * vulnerability/update identifiers, rule and technique IDs, product names, ports and event IDs. + */ +const publicNumericTokens: RegExp[] = [ + /\bS-1-\d+(?:-\d+)+\b/giu, + /\[[A-Z]+(?:_[A-Z]+)*_\d+\]/gu, + /\b(?:CVE|CWE|KB|MS)-?\d+(?:-\d+)*\b/giu, + /\bT\d{4}(?:\.\d{3})?\b/gu, + /\b[A-Z][A-Za-z]{0,9}[.-]\d+(?:[.-]\d+)*\b/gu, + /\b\d+(?:\.\d+)+\b/gu, + // Versions written directly after a name: `Tls1.2`, `Graph v1.0`, `SMBv3.1.1`. + /\b[A-Za-z]+\d+(?:\.\d+)+\b/gu, + /\b(?:Windows(?:\s+Server)?|Exchange(?:\s+Server)?|SQL\s+Server|Office|SharePoint)\s+(?:\d{4}(?:[- ]?R2)?|9[58]|1[01]|8\.1|[78])\b/giu, + // Server releases named without a product ("pre 2012-R2 schema"). + /\b(?:19|20)\d{2}[- ]R2\b/gu, + // References to numbered parts of the same guidance or its source ("step 2", "Option 1", "Page 16"). + /\b(?:steps?|options?|page)\s+\d{1,3}\b/giu, + /\b(?:Microsoft|Office|Windows|Dynamics)\s+365\b/giu, + // Documented attribute flags (`adminCount=1`, `adminCount attribute set to 1`) and CISA baseline sections. + /\b[a-z]+[A-Z][A-Za-z]*(?:\s+attribute\s+set\s+to\s+|\s*=\s*)\d+\b/gu, + /\bCISA\s+\d+(?=\.)/gu, + /\b(?:event(?:\s+id)?|port)\s+\d+\b/giu, + /\b\d+-bit\b/giu +]; + +function protectedTokenKey(index: number): string { + let result = ''; + let rest = index; + do { + result = String.fromCharCode(97 + (rest % 26)) + result; + rest = Math.floor(rest / 26) - 1; + } while (rest >= 0); + return result; +} + +/** + * Exact dates, durations and counts in scanner prose fingerprint the assessed environment and + * calibrate attack effort ("1 DC", "15 accounts", "55 days", "0 day(s) ago"). They become typed + * placeholders; the originals remain in internal analysis. + */ +/** Numbered steps ("1. Open …", "2) Select …") structure guidance; they are not observed quantities. */ +const listMarker = /^[ \t]*\d{1,3}[.)](?=[ \t])/gmu; + +/** + * Inline ordered lists ("… center. 2. Select …", "Activate the rules. 2) Network …"). A marker counts + * only inside a run 1, 2, 3 … of at least two items with the same delimiter, so a lone count such as + * "set to 1. In addition" is not mistaken for a list. + */ +function inlineOrderedListMarkers(text: string): Array<[number, number]> { + const candidates = [...text.matchAll(/(? ({ start: match.index, end: match.index + match[0].length, number: Number(match[1]), delimiter: match[2] })); + const accepted = new Set(); + candidates.forEach((candidate, index) => { + if (candidate.number !== 1 || accepted.has(index)) return; + const run = [index]; + for (let next = index + 1, expected = 2; next < candidates.length; next += 1) { + if (candidates[next].delimiter !== candidate.delimiter) continue; + const number = candidates[next].number; + // Sources occasionally repeat or skip one step number ("3. … 3. … 5. …"); those stay in the list. + if (number === expected || number === expected + 1 || (number === expected - 1 && run.length > 1)) { + run.push(next); + expected = Math.max(expected, number + 1); + } + } + if (run.length >= 2) run.forEach(position => accepted.add(position)); + }); + return [...accepted].map(position => [candidates[position].start, candidates[position].end]); +} + +const durationUnit = String.raw`(?:(?:second|minute|hour|day|week|month|year)(?:s|\(s\))?|characters?|character\(s\)|` + + String.raw`Sekunden?|Minuten?|Stunden?|Tagen?|Tage?|Wochen?|Monaten?|Monate?|Jahren?|Jahre?|Zeichen)`; + +/** + * Recommendation thresholds ("at least 14 characters", "more than 180 days", "15 or more") are + * vendor guidance, not facts about the environment. Only the recommendation field uses this + * exception; titles and descriptions carry scanner observations and stay fully redacted. + */ +const recommendationThresholds: RegExp[] = [ + new RegExp(String.raw`\b(?:at (?:least|most)|minimum(?: of)?|maximum(?: of)?|no (?:more|less|fewer) than|(?:more|less|fewer|greater|higher|lower|older) than|up to|every|within)\s+\d+(?:[.,]\d+)?(?:\s*${durationUnit}(?!\p{L}))?`, 'giu'), + new RegExp(String.raw`(?=|<=|=)\s*\d+\b/gu, + /\b(?:zero|one|two|three)\s*\(\d+\)/giu, + /(?(); + const protect = (text: string, pattern: RegExp) => text.replace(pattern, match => { + // Grouped thousands (1.015 or 1,015) are counts, not versions; a retained address-range boundary + // such as 10.255.255.255 is not a grouped number. + if (/^\d{1,3}(?:[.,]\d{3})+$/.test(match) && !publicAddressRangeBoundaries.has(match)) return match; + const key = `\uE000${protectedTokenKey(protectedTokens.size)}\uE001`; + protectedTokens.set(key, match); + return key; + }); + let redacted = value + .replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/gu, '[date]') + .replace(/\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b/gu, '[date]') + .replace(/\b\d{1,2}:\d{2}(?::\d{2})?\b/gu, '[time]'); + // Inline runs first, so a run that starts at a line start ("1. \u2026" newline "\u2026 2. \u2026") stays complete. + for (const [start, end] of inlineOrderedListMarkers(redacted).sort((left, right) => right[0] - left[0])) { + const key = `\uE000${protectedTokenKey(protectedTokens.size)}\uE001`; + protectedTokens.set(key, redacted.slice(start, end)); + redacted = redacted.slice(0, start) + key + redacted.slice(end); + } + redacted = protect(redacted, listMarker); + if (keepRecommendationThresholds) { + for (const pattern of recommendationThresholds) redacted = protect(redacted, pattern); + } + redacted = redacted + .replace(/(? protectedTokens.get(key) ?? key); + } while (restored !== redacted); + return restored; +} + +/** + * Group Policy and registry paths are public setting locations. Their segments + * (`Configuration\Policies`, `System\CurrentControlSet`) are not DOMAIN\account pairs. A span runs + * from a recognized root to the end of the line or a semicolon. + */ +const publicPathRoots = new RegExp(String.raw`(?:\b(?:Computer|User) Configuration|\bHK(?:LM|CU|U|CR|CC)\b|\bHKEY_[A-Z_]+|` + + String.raw`\b(?:Software|System)(?=\\(?:Policies|Microsoft|CurrentControlSet|Classes|WOW6432Node)\b)|` + + String.raw`\bApplications and Services Logs|` + + String.raw`\b(?:Windows Settings|Security Settings|Local Policies|Account Policies|Administrative Templates|` + + String.raw`Windows Components|Advanced Audit Policy Configuration))\\[^;\r\n]*`, 'giu'); + +function publicPathSpans(value: string): Array<[number, number]> { + return [...value.matchAll(publicPathRoots)].map(match => [match.index, match.index + match[0].length]); +} + +/** + * Hosts of vendor and standards documentation. Tenant hosts (`*.onmicrosoft.com`, `*.sharepoint.com`, + * `login.microsoftonline.com/`) are deliberately absent and stay redacted. + */ +const publicReferenceHosts = [ + 'microsoft.com', 'cloud.microsoft', 'azure.com', 'office.com', 'github.com', 'maester.dev', 'cisa.gov', 'dhs.gov', + 'cisecurity.org', 'mitre.org', 'nist.gov', 'pingcastle.com', 'semperis.com', 'owasp.org', 'ietf.org', + 'rfc-editor.org', 'bsi.bund.de', 'aka.ms', 'windows.com', 'whitehouse.gov', 'citrix.com' +]; + +function isPublicReferenceHost(host: string): boolean { + const normalized = host.toLocaleLowerCase('en-US').replace(/\.$/, ''); + return publicReferenceHosts.some(domain => normalized === domain || normalized.endsWith(`.${domain}`)); +} + +/** Document and program names such as `pfirewall.log`, `gpedit.msc` or `aad.md` are not domains. */ +const publicFileName = /\.(?:log|exe|dll|msc|msi|cpl|sys|inf|ini|admx|adml|md|rego|ps1|psm1|psd1|json|xml|csv|txt|evtx|etl|cab|reg|pol|cer|pem|pfx|bat|cmd|vbs|html?|aspx?|pdf|docx?|xlsx?|pptx?|png|svg)$/iu; + +/** Dotted PascalCase code identifiers (`RecommendationDetails.ReactView`); DNS names are not written this way. */ +const pascalCaseIdentifier = /^[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*(?:\.[A-Z][A-Za-z0-9]*)+$/u; + +/** .NET and Windows API namespaces (`System.DateTime`, `Windows.Storage`) and `ASP.NET`. */ +const codeNamespace = /^(?:(?:System|Microsoft|Windows)(?:\.[A-Z][A-Za-z0-9]*)+|ASP\.NET)$/u; + +/** Sentence-initial words that follow a missing space after a period ("endpoint.The"); never a TLD. */ +// Two-letter words are excluded because they collide with country TLDs (.in, .it, .to, .as). +const sentenceStarter = /^(?:The|This|That|These|Those|Its|For|When|Where|While|See|Note|You|Please|Use|Any|All|Also|After|Before|Once|Otherwise)$/u; + +/** Microsoft Graph permission names (`Directory.ReadWrite.All`, `Policy.ReadWrite.AuthenticationMethod`). */ +const graphPermission = /^[A-Z][A-Za-z]+\.(?:Read|ReadWrite|ReadBasic|Write|Manage|Send|Create|Delete|Invite|Export|AccessAsUser)[A-Za-z]*(?:\.[A-Z][A-Za-z]+)*$/u; + +function isPublicReferenceUrl(value: string): boolean { + try { + const url = new URL(value.replace(/[.,;:!?]+$/u, '')); + if (!/^https?:$/u.test(url.protocol) || url.username || url.password || !isPublicReferenceHost(url.hostname)) return false; + // A link that already carries a redaction placeholder or an identifier is tenant-specific. + if (/\[|\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/iu.test(value)) return false; + // Azure resource paths name customer resources (resource groups, vaults, workspaces). + if (/\/(?:subscriptions|resourceGroups|providers|resource)\//iu.test(decodeURIComponent(value))) return false; + const rest = decodeURIComponent(`${url.pathname}${url.search}${url.hash}`); + if (/@|onmicrosoft|sharepoint/iu.test(rest)) return false; + // Any further host-like token in the path must itself be public, a file name or a PascalCase + // code identifier such as the Entra blade `RecommendationDetails.ReactView`. + return [...rest.matchAll(/(?:[a-z0-9-]+\.)+[a-z]{2,}/giu)] + .every(match => isPublicReferenceHost(match[0]) || publicFileName.test(match[0]) || pascalCaseIdentifier.test(match[0]) + // Microsoft module names in documentation paths (`/module/microsoft.powershell.core/`). + || /^microsoft\.[a-z]/iu.test(match[0])); + } catch { + return false; + } +} + +const ipv4Octet = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`; +/** A standalone IPv4 address, never part of a longer dotted sequence such as CIS 18.9.20.1.1. */ +const ipv4Address = new RegExp(String.raw`(?/giu, '\n') @@ -237,33 +500,79 @@ function redactNarrative(item: UnifiedResultProjection, value?: string): string .replace(/"/giu, '"') .replace(/'/giu, "'"); const cisRuleId = disclosureTool(item).id === 'cis-cat' ? safePublicRuleId(item) : undefined; + // Public references are held out of every later rule (hosts, quantities) and restored at the end. + const references = new Map(); + const keepReference = (value: string) => { + const key = `\uE100${protectedTokenKey(references.size)}\uE101`; + references.set(key, value); + return key; + }; redacted = redacted + // Links are judged first and as a whole, before any other placeholder can cut them: a GUID, + // address or account inside a link rejects the complete link (`…/subscriptions//resourceGroups/…`). + // Only `]` ends a link, for markdown links whose text is the URL itself. + .replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()\]]+/giu, match => { + // Sentence punctuation after a link is not part of it. + const url = match.replace(/[.,;:!?]+$/u, ''); + return `${isPublicReferenceUrl(url) ? keepReference(url) : '[url]'}${match.slice(url.length)}`; + }) .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, '[email]') .replace(/\bS-1-\d+(?:-\d+){2,}\b/giu, match => isPublicWindowsSid(match) ? match : '[sid]') - .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[id]') - .replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]') + // A documented Microsoft GUID is held out like a public reference, so its digit groups are not counts. + .replace(guidPattern, match => isPublicGuid(match) ? keepReference(match) : '[id]') + .replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]'); + const pathSpans = publicPathSpans(redacted); + redacted = redacted .replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$-]+\b/gu, (match, offset: number, input: string) => { - const start = input.lastIndexOf('\n', offset) + 1; - const end = input.indexOf('\n', offset); - const line = input.slice(start, end < 0 ? undefined : end).trim(); - return cisRuleId && /^(?:Computer|User) Configuration\\Administrative Templates\\/iu.test(line) - && publicCisPolicyPathPairs.has(match.toLocaleLowerCase('en-US')) - ? match : '[account]'; + // Inside a path, or the direct continuation of a path the source hard-wrapped mid-segment + // ("…\Terminal" + newline + "Server\UserConfig"): the span ends in a segment character and + // the match starts the very next line. + if (pathSpans.some(([start, end]) => offset >= start && (offset < end + || (/^\r?\n$/u.test(input.slice(end, offset)) && /[\p{L}\p{N}]/u.test(input[end - 1] ?? ''))))) return match; + if (input[offset - 1] === '\\' || input[offset + match.length] === '\\') return match; + const authority = input.slice(0, offset) + match.slice(0, match.indexOf('\\')); + return publicAccountAuthorities.test(authority) ? match : '[account]'; }) .replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[network-path]') .replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]') - .replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()]+/giu, '[url]') - .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, match => cisRuleId === match ? match : '[ip]') + .replace(ipv4Address, (match, offset: number, input: string) => + (cisRuleId && (cisRuleId === match || cisRuleId.startsWith(`${match}.`))) + || /\bRules?\s+$/iu.test(input.slice(Math.max(0, offset - 8), offset)) + || publicAddressRangeBoundaries.has(match) ? match : '[ip]') .replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/giu, '[mac]') - .replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match => cisRuleId && /\.(?:admx|adml)$/iu.test(match) ? match : '[domain]') - .replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?|Limited)\b/giu, '[organization]'); + .replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, (match, offset: number, input: string) => { + if (input[offset - 1] === '@') return '[domain]'; + if (isPublicReferenceHost(match)) return keepReference(match); + if (publicFileName.test(match) || graphPermission.test(match) || codeNamespace.test(match) + || pascalCaseIdentifier.test(match)) return match; + // Property access in code samples (`('Fido2') .keyRestrictions.isEnforced`). + if (input[offset - 1] === '.') return match; + // A missing space after a sentence ("service.This indicator") is not a host name. + if (sentenceStarter.test(match.slice(match.lastIndexOf('.') + 1)) && /^\s/u.test(input.slice(offset + match.length))) return match; + // Dotted policy identifiers such as ScubaGear `MS.AAD.2.3` continue with a numeric segment. + if (/^\.\d/u.test(input.slice(offset + match.length))) return match; + return '[domain]'; + }) + // Legal forms are matched in their registered spelling only. `Limited` is omitted: in guidance it is + // an ordinary word ("Limited number of Global Admins", "sharing limited to existing guests"). + .replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?)(?![\p{L}\p{N}_])/gu, '[organization]'); for (const literal of privateLiterals(item)) { redacted = redacted.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]'); } + // CIS-CAT prose is benchmark text; its numbers are public recommendations, not observations. + if (disclosureTool(item).id !== 'cis-cat') redacted = redactEnvironmentQuantities(redacted, field === 'recommendation'); + const literals = privateLiterals(item).map(literal => literal.toLocaleLowerCase('en-US')); + redacted = redacted.replace(/\uE100[a-z]+\uE101/gu, key => { + const reference = references.get(key) ?? key; + // A public reference that still carries a private literal is not public. + return literals.some(literal => reference.toLocaleLowerCase('en-US').includes(literal)) + ? (reference.includes('://') ? '[url]' : isPublicGuid(reference) ? '[id]' : '[domain]') : reference; + }); redacted = redacted .replace(/(?:\[redacted\]\s*){2,}/gu, '[redacted] ') .replace(/[ \t]{2,}/g, ' ') - .replace(/\s+([,.;:!?])/g, '$1') + // Only sentence punctuation: " .onmicrosoft.com" or " .NET" must keep its leading space. + .replace(/\s+([,.;:!?])(?![\p{L}\p{N}])/gu, '$1') .replace(/\s*\n\s*/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); @@ -298,13 +607,13 @@ function controlledTitle(item: UnifiedResultProjection): string { } const tool = disclosureTool(item); const ruleId = safePublicRuleId(item); - const title = redactNarrative(item, item.title); + const title = redactNarrative(item, item.title, 'title'); if (title) return title; return ruleId ? `${tool.label} public rule ${ruleId}` : `${tool.label} finding`; } function controlledDescription(item: UnifiedResultProjection): string | undefined { - if (item.sourceKind !== 'policy-evaluation') return redactNarrative(item, item.description); + if (item.sourceKind !== 'policy-evaluation') return redactNarrative(item, item.description, 'description'); if (item.policyClassification === 'conflict') { return 'Different retained technical states were detected for the same Group Policy setting.'; } @@ -319,18 +628,128 @@ function controlledDiagnosticCodes(values?: string[]): string[] | undefined { return safe?.length ? safe : undefined; } +type FieldDisclosure = 'disclose' | 'withhold'; + +/** + * Every projection field must be classified here; a new field fails compilation until it is. + * `disclose` fields are either safe as-is or rebuilt/redacted below; scope handles are replaced + * by removeResultDbScopeIdentifiers and the JSONL export after filtering. `withhold` fields are + * internal analysis only: customer identities, provenance, source timestamps and client-owned + * review/risk-acceptance state. A per-finding `score` is withheld: scanner indicator scores (Purple + * Knight) and computed rule points (PingCastle) scale with the affected objects and calibrate attack effort. + */ +const RESULT_DB_FIELD_DISCLOSURE: Record = { + resultProjectionId: 'disclose', sourceKind: 'disclose', sourceResultId: 'disclose', + projectId: 'disclose', assessmentId: 'disclose', collectionRunId: 'disclose', scanRunId: 'disclose', + gpoSnapshotId: 'disclose', evaluationRunId: 'disclose', + sourceTool: 'disclose', sourceToolScope: 'disclose', sourceToolVersion: 'withhold', + sourceProfileId: 'disclose', sourceProfileVersion: 'disclose', mappingRuleVersion: 'disclose', + normalizationRuleVersion: 'disclose', presentationVersion: 'disclose', processingCompatibilityKey: 'disclose', + processingStatus: 'disclose', reprocessingAvailable: 'disclose', + sourceRuleId: 'disclose', canonicalDefinitionId: 'withhold', importArtifactId: 'withhold', + sourceViewId: 'withhold', sourceLocator: 'withhold', + environmentProperty: 'disclose', environmentSubjectType: 'disclose', environmentSubjectIdentity: 'withhold', + environmentValueType: 'disclose', environmentState: 'disclose', environmentOriginalValue: 'withhold', + environmentNormalizedValue: 'disclose', environmentSectionKey: 'withhold', environmentAttributes: 'withhold', + supplementalDisclosurePolicyVersion: 'disclose', supplementalDisclosureClass: 'disclose', + policyScope: 'disclose', policyDeliveryMechanism: 'disclose', policyState: 'disclose', + policyIdentityStatus: 'disclose', policyIdentityKey: 'withhold', policyValues: 'withhold', + policyDiagnosticCodes: 'disclose', policyBackupInstanceId: 'withhold', policyBackupLinkStatus: 'disclose', + policyPreferenceCse: 'disclose', policyPreferenceAction: 'disclose', policyPreferenceTargeting: 'disclose', + policyGpoId: 'withhold', policyNativeBackupId: 'withhold', policySourceSequence: 'withhold', + policyBaselineId: 'withhold', policyBaselineLabel: 'disclose', policyBaselineValue: 'disclose', + policyBaselineVariantAssessment: 'disclose', policyActualValue: 'withhold', policyComparisonRelation: 'disclose', + policyCustomerState: 'disclose', policyClassification: 'disclose', policySourceCount: 'withhold', + title: 'disclose', description: 'disclose', recommendation: 'disclose', affectedAsset: 'withhold', score: 'withhold', + severityOriginal: 'disclose', severityNormalized: 'disclose', outcomeOriginal: 'disclose', + outcomeNormalized: 'disclose', technicalExecutionState: 'disclose', + resultType: 'disclose', isIoe: 'disclose', isDeviation: 'disclose', exposureState: 'disclose', + effectiveState: 'disclose', lifecycleState: 'disclose', mappingConfidence: 'disclose', + applicabilityState: 'disclose', completenessState: 'disclose', scopeState: 'disclose', + effectivenessVerification: 'disclose', scopeConfidence: 'disclose', + reviewStatus: 'withhold', reviewDisposition: 'withhold', reviewPriority: 'withhold', + reviewOwner: 'withhold', reviewDueDate: 'withhold', + sourceObservedAt: 'withhold', importedAt: 'disclose', evaluatedAt: 'withhold', createdAt: 'disclose', + categories: 'disclose', frameworkReferences: 'disclose', disclosureTrace: 'disclose' +}; + +/** Drops withheld and unclassified fields, so fields added at runtime cannot pass through. */ +function onlyDisclosedFields(item: UnifiedResultProjection): UnifiedResultProjection { + return Object.fromEntries(Object.entries(item).filter(([key]) => + RESULT_DB_FIELD_DISCLOSURE[key as keyof UnifiedResultProjection] === 'disclose')) as unknown as UnifiedResultProjection; +} + /** * Fail-closed public projection used by Result DB and every downstream LLM/export path. * Internal analysis keeps the canonical source records and provenance unchanged. */ -export function discloseResultDbProjection(item: UnifiedResultProjection): UnifiedResultProjection { +export function discloseResultDbProjection(item: UnifiedResultProjection, identityGuard?: ResultDbIdentityGuard): UnifiedResultProjection { + return applyResultDbIdentityGuard(onlyDisclosedFields(disclosedValues(item)), identityGuard); +} + +const tracedFields = [ + 'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue', + 'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories' +] as const satisfies ReadonlyArray; + +const untracedWithheldFields = new Set([ + 'reviewStatus', 'reviewDisposition', 'reviewPriority', 'reviewOwner', 'reviewDueDate' +] satisfies Array); + +function hasValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && value.length === 0); +} + +/** + * The materialized Result DB row: the disclosed projection plus its content-free redaction trace. + * The trace is derived from the placeholders each step added per field, so it describes exactly the + * stored row and never repeats an original value. + */ +export function discloseResultDbProjectionWithTrace( + item: UnifiedResultProjection, + identityGuard?: ResultDbIdentityGuard +): UnifiedResultProjection { + const disclosed = onlyDisclosedFields(disclosedValues(item)); + const guarded = applyResultDbIdentityGuard(disclosed, identityGuard); + const redactions: ResultDbDisclosureTrace['redactions'] = []; + for (const field of tracedFields) { + for (const entry of addedPlaceholders(item[field], disclosed[field])) redactions.push({ field, ...entry }); + for (const entry of addedPlaceholders(disclosed[field], guarded[field], rule => rule === 'private-value' ? 'identity-guard' : rule)) { + redactions.push({ field, ...entry }); + } + } + if (item.policyBaselineLabel && guarded.policyBaselineLabel && item.policyBaselineLabel !== guarded.policyBaselineLabel) { + redactions.push({ field: 'policyBaselineLabel', rule: 'baseline-family', count: 1 }); + } + const collapsed = (item.policyBaselineVariantAssessment?.variants.length ?? 0) - (guarded.policyBaselineVariantAssessment?.variants.length ?? 0); + if (collapsed > 0) redactions.push({ field: 'policyBaselineVariantAssessment', rule: 'baseline-expectations-collapsed', count: collapsed }); + // Client-owned review state is withheld without a trace entry: even its presence would reveal + // which findings the client has triaged or prioritized. + const withheldFields = Object.keys(item) + .filter(key => !untracedWithheldFields.has(key)) + .filter(key => hasValue(item[key as keyof UnifiedResultProjection]) && !hasValue(guarded[key as keyof UnifiedResultProjection])) + .sort(); + const guardWithheldFields = tracedFields.filter(field => JSON.stringify(guarded[field] ?? '').includes('[withheld by identity guard]')); + return { + ...guarded, + disclosureTrace: { + version: 1, + disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION, + identityGuardVersion: RESULT_DB_IDENTITY_GUARD_VERSION, + redactions, + withheldFields, + guardWithheldFields + } + }; +} + +function disclosedValues(item: UnifiedResultProjection): UnifiedResultProjection { const tool = disclosureTool(item); const policyEvaluation = item.sourceKind === 'policy-evaluation'; const policyRedactor = policyEvaluation ? new PolicyValueRedactor() : undefined; const rawPublicRuleId = safePublicRuleId(item); const publicRuleId = policyRedactor && rawPublicRuleId ? policyRedactor.text(rawPublicRuleId, false) : rawPublicRuleId; const policyDescription = policyRedactor ? safePolicyNarrative(item, policyRedactor, item.description) : undefined; - const publicActualValue = policyRedactor ? policyRedactor.json(item.policyActualValue) : undefined; const publicBaselineValue = policyRedactor ? policyRedactor.json(item.policyBaselineValue) : undefined; const publicBaselineVariants = policyRedactor ? safeBaselineVariantAssessment(item.policyBaselineVariantAssessment, policyRedactor) @@ -357,18 +776,21 @@ export function discloseResultDbProjection(item: UnifiedResultProjection): Unifi policySourceSequence: undefined, policyIdentityKey: undefined, policyBaselineId: undefined, - policyBaselineLabel: policyRedactor ? safeBaselineLabel(item, policyRedactor) : undefined, + policyBaselineLabel: policyRedactor ? safeBaselineLabel(item) : undefined, policyBaselineValue: publicBaselineValue, policyBaselineVariantAssessment: publicBaselineVariants, - policyActualValue: publicActualValue, + policyActualValue: undefined, + policySourceCount: undefined, policyDiagnosticCodes: controlledDiagnosticCodes(item.policyDiagnosticCodes), - title: policyRedactor ? policyRedactor.text(controlledTitle(item)) : controlledTitle(item), + // A GPO title is the technical setting identity (ADMX name, registry key or section/key), like the + // rule ID; `Software\Policies` is a registry path, not a DOMAIN\account pair. + title: policyRedactor ? policyRedactor.text(controlledTitle(item), false) : controlledTitle(item), description: policyEvaluation ? [policyDescription, controlledDescription(item)].filter(Boolean).join(' ') : controlledDescription(item), recommendation: policyRedactor ? safePolicyNarrative(item, policyRedactor, item.recommendation) - : redactNarrative(item, item.recommendation), + : redactNarrative(item, item.recommendation, 'recommendation'), affectedAsset: undefined, severityOriginal: item.severityNormalized, outcomeOriginal: item.outcomeNormalized, diff --git a/src/domain/result-db-identity-guard.ts b/src/domain/result-db-identity-guard.ts new file mode 100644 index 0000000..45bf254 --- /dev/null +++ b/src/domain/result-db-identity-guard.ts @@ -0,0 +1,219 @@ +import type { AssessmentProject, JsonValue } from './canonical-model'; +import type { EnvironmentObservation } from './environment-context'; +import type { UnifiedResultProjection } from './unified-result'; + +/** + * Client-specific identity guard for the Result DB (user decision 2026-09-28). + * + * Pattern-based redaction cannot recognize a host name such as `DC01` or a person's name in free + * text. The guard therefore collects the identities a client's own data already names (client name, + * domain and tenant names, host and account names, affected assets, review owners) and removes them + * from every disclosed Result DB row of that client, independent of their textual form. It runs inside + * the disclosure projection, so API queries, single reads, the UI and the JSONL export are all covered. + */ + +export const RESULT_DB_IDENTITY_GUARD_VERSION = '1.3.0'; + +export interface ResultDbIdentitySources { + projects: AssessmentProject[]; + environmentObservations: EnvironmentObservation[]; + affectedAssets: Array; + reviewOwners: Array; + /** Account-like values from internal analysis (`DOMAIN\user`, UPNs, GPO principals). */ + principals?: Array; +} + +/** Generic words that must never become client identities, even when a client uses them as a label. */ +const genericWords = new Set([ + 'local', 'corp', 'domain', 'domains', 'forest', 'tenant', 'admin', 'admins', 'administrator', 'administrators', + 'user', 'users', 'guest', 'guests', 'default', 'server', 'servers', 'computer', 'computers', 'windows', + 'microsoft', 'system', 'service', 'services', 'network', 'global', 'internal', 'intern', 'office', 'group', + 'groups', 'account', 'accounts', 'builtin', 'authority', 'everyone', 'unknown', 'none', 'true', 'false', + // Documented built-in accounts and policies named in public scanner guidance. + 'krbtgt', 'defaultaccount', 'wdagutilityaccount', 'localhost', 'default domain policy', + 'default domain controllers policy', 'domain controllers', 'domain admins', 'enterprise admins', 'schema admins', + // Well-known Active Directory groups, named in public guidance for every client. + 'server operators', 'account operators', 'backup operators', 'print operators', 'dns admins', 'dnsadmins', + 'dnsupdateproxy', 'cert publishers', 'protected users', 'remote desktop users', 'group policy creator owners', + 'key admins', 'enterprise key admins', 'read-only domain controllers', 'cloneable domain controllers', + 'domain users', 'domain computers', 'domain guests', 'pre-windows 2000 compatible access', + // Scanner and product names. + 'pingcastle', 'maester', 'purple knight', 'purpleknight', 'cis-cat' +]); + +const publicAuthorities = /^(?:BUILTIN|NT AUTHORITY|NT SERVICE|NT VIRTUAL MACHINE|RESTRICTED SERVICES|Window Manager|Font Driver Host)$/iu; + +/** Environment properties whose values name the client (domain, forest, tenant, host). */ +// Only the client's own domain, forest, host and tenant properties; other `*.name` properties (for +// example certificate template names) hold public object names. +const identityProperty = /^(?:domain|forest|host|computer|dc|entra|tenant)\.(?:fqdn|name|netbios|netbios_name|hostname|dns_name|tenant_name|tenant_id|domain)$/iu; + +function normalized(value: string): string { + return value.toLocaleLowerCase('en-US').replace(/[^\p{L}\p{N}]+/gu, ''); +} + +function usable(value: string): boolean { + const trimmed = value.trim(); + return trimmed.length >= 3 && trimmed.length <= 200 && !/^\d+$/u.test(trimmed) + && !genericWords.has(trimmed.toLocaleLowerCase('en-US')) && !/^S-1-\d/iu.test(trimmed); +} + +function expand(value: string): string[] { + const trimmed = value.trim(); + const parts = [trimmed]; + const account = /^([^\\/@\s]+)\\(.+)$/u.exec(trimmed); + if (account) { + if (!publicAuthorities.test(account[1])) parts.push(account[1], account[2]); + else return []; + } + const upn = /^([^@\s]+)@([^@\s]+\.[^@\s]+)$/u.exec(trimmed); + if (upn) parts.push(upn[1], upn[2]); + for (const part of [...parts]) { + // The first label of a host or domain name (`sevenkingdoms` of `sevenkingdoms.local`) names the client. + const labels = part.split('.'); + if (labels.length > 1 && /^[\p{L}\p{N}-]+$/u.test(labels[0]) && labels[0].length >= 5) parts.push(labels[0]); + } + return parts; +} + +/** + * Host, account and domain names: a digit (`DC01`), a dot, backslash or `@` (`corp.local`, + * `CORP\\jon`, `jon@corp.local`), or an all-lowercase sAMAccountName (`tywin`). Capitalized words and + * phrases without such marks are categories or public object names and are not taken from asset columns. + */ +function identityShaped(value: string): boolean { + const trimmed = value.trim(); + if (/\s/u.test(trimmed) && !/[\\@.]/u.test(trimmed)) return false; + return /\d|[.\\@]/u.test(trimmed) || /^[a-z][a-z0-9_-]{3,}$/u.test(trimmed); +} + +function strings(value: JsonValue | undefined): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(strings); + return []; +} + +/** Longest first, so `sevenkingdoms.local` is replaced before `sevenkingdoms`. */ +export function collectResultDbIdentityLiterals(sources: ResultDbIdentitySources): string[] { + const candidates: string[] = []; + for (const project of sources.projects) { + candidates.push(project.name); + for (const value of Object.values(project.attributes ?? {})) candidates.push(...strings(value)); + } + for (const observation of sources.environmentObservations) { + // Only identity properties name the client; subject and section labels are report vocabulary. + if (!identityProperty.test(observation.property)) continue; + candidates.push(...strings(observation.originalValue), ...strings(observation.normalizedValue)); + if (observation.subject.identity) candidates.push(observation.subject.identity); + } + // Scanner asset columns also hold categories and public object names ("Registry", "SubCA"); + // only values shaped like an identity are taken from them. + for (const value of sources.affectedAssets) if (value && identityShaped(value)) candidates.push(value); + for (const value of [...sources.reviewOwners, ...(sources.principals ?? [])]) if (value) candidates.push(value); + const unique = new Map(); + for (const candidate of candidates.flatMap(expand)) { + const trimmed = candidate.trim(); + if (!usable(trimmed)) continue; + const key = trimmed.toLocaleLowerCase('en-US'); + if (!unique.has(key)) unique.set(key, trimmed); + } + return [...unique.values()].sort((left, right) => right.length - left.length || left.localeCompare(right)); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export class ResultDbIdentityGuard { + private readonly patterns: RegExp[]; + private readonly variantPatterns: RegExp[]; + + constructor(literals: readonly string[]) { + this.patterns = literals.map(literal => + new RegExp(`(? value.length >= 5))] + .map(value => new RegExp(`(? pattern.test(result)) ? '[withheld by identity guard]' : result; + } + + json(value: T): T { + if (typeof value === 'string') return this.text(value) as T; + if (Array.isArray(value)) return value.map(item => this.json(item)) as T; + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, this.json(child)])) as T; + } + return value; + } +} + +/** + * Account forms only: `DOMAIN\name` with exactly one backslash and no registry root, or a UPN. + * GPO values also hold registry keys and paths (`Software\Policies\…`), which name no client. + */ +function accountLike(value: string): boolean { + const trimmed = value.trim(); + const account = /^([^\\\s]+)\\([^\\]+)$/u.exec(trimmed); + if (account) return !/^(?:HK[A-Z_]*|HKEY_\w+|Software|System|Machine|User|Policies|Registry|Classes)$/iu.test(account[1]); + return /^[^@\s]+@[^@\s]+\.[a-z]{2,}$/iu.test(trimmed); +} + +function accountLikeStrings(value: unknown): string[] { + if (typeof value === 'string') return accountLike(value) ? [value] : []; + if (Array.isArray(value)) return value.flatMap(accountLikeStrings); + if (value && typeof value === 'object') return Object.values(value).flatMap(accountLikeStrings); + return []; +} + +/** + * Builds one guard per client from its complete internal data. `internalResults` must be the + * unfiltered internal-analysis projection of those clients, never a filtered query page. + */ +export function buildResultDbIdentityGuards(input: { + projects: AssessmentProject[]; + environmentObservations: Array<{ projectId: string; observation: EnvironmentObservation }>; + internalResults: UnifiedResultProjection[]; +}): Map { + const guards = new Map(); + for (const project of input.projects) { + const results = input.internalResults.filter(item => item.projectId === project.id); + guards.set(project.id, new ResultDbIdentityGuard(collectResultDbIdentityLiterals({ + projects: [project], + environmentObservations: input.environmentObservations + .filter(entry => entry.projectId === project.id).map(entry => entry.observation), + affectedAssets: results.map(item => item.affectedAsset), + reviewOwners: results.map(item => item.reviewOwner), + principals: results.flatMap(item => [ + ...accountLikeStrings(item.policyActualValue), ...accountLikeStrings(item.policyValues), + ...accountLikeStrings(item.environmentSubjectIdentity) + ]) + }))); + } + return guards; +} + +/** Content fields of a disclosed row; identifiers, enums and version fields are never rewritten. */ +const guardedFields = [ + 'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue', + 'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories' +] as const satisfies ReadonlyArray; + +export function applyResultDbIdentityGuard(item: UnifiedResultProjection, guard?: ResultDbIdentityGuard): UnifiedResultProjection { + if (!guard?.size) return item; + const guarded: UnifiedResultProjection = { ...item }; + for (const field of guardedFields) { + if (guarded[field] !== undefined) (guarded as unknown as Record)[field] = guard.json(guarded[field]); + } + return guarded; +} diff --git a/src/domain/result-db-materialization.ts b/src/domain/result-db-materialization.ts new file mode 100644 index 0000000..603b2d9 --- /dev/null +++ b/src/domain/result-db-materialization.ts @@ -0,0 +1,58 @@ +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 type { UnifiedResultProjection } from './unified-result'; + +/** + * Materialized Result DB (user decision 2026-09-28): rows are disclosed, checked and stored when the + * source data changes. UI, API and JSONL export read only these stored rows, so what is shown is + * exactly what is delivered; each row carries its content-free redaction trace. + */ +export interface ResultDbMaterializationStatus { + /** `current`: stored rows match the current repository revision and policy versions. */ + state: 'current' | 'outdated' | 'not-built'; + sourceRevision?: number; + repositoryRevision: number; + disclosurePolicyVersion?: string; + identityGuardVersion?: string; + materializedAt?: string; + rowCount: number; + redactionCount: number; + /** Rows in which the identity guard withheld at least one complete field. */ + guardWithheldRowCount: number; +} + +export interface MaterializedResultDb { + rows: UnifiedResultProjection[]; + status: ResultDbMaterializationStatus; +} + +export function materializeResultDb( + sourceResults: UnifiedResultProjection[], + guards: Map, + sourceRevision: number, + materializedAt: string +): MaterializedResultDb { + const rows = sourceResults.map(item => discloseResultDbProjectionWithTrace(item, guards.get(item.projectId))); + return { + rows, + status: { + state: 'current', + sourceRevision, + repositoryRevision: sourceRevision, + disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION, + identityGuardVersion: RESULT_DB_IDENTITY_GUARD_VERSION, + materializedAt, + rowCount: rows.length, + redactionCount: rows.reduce((sum, row) => sum + + (row.disclosureTrace?.redactions.reduce((total, entry) => total + entry.count, 0) ?? 0), 0), + guardWithheldRowCount: rows.filter(row => row.disclosureTrace?.guardWithheldFields.length).length + } + }; +} + +/** 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 + && status.disclosurePolicyVersion === RESULT_DB_DISCLOSURE_POLICY_VERSION + && status.identityGuardVersion === RESULT_DB_IDENTITY_GUARD_VERSION; +} diff --git a/src/domain/result-db-supplemental-disclosure.ts b/src/domain/result-db-supplemental-disclosure.ts index d4ba6b2..5296008 100644 --- a/src/domain/result-db-supplemental-disclosure.ts +++ b/src/domain/result-db-supplemental-disclosure.ts @@ -1,7 +1,7 @@ import type { JsonValue } from './canonical-model'; import type { EnvironmentCollectionState, EnvironmentObservation, EnvironmentValueType } from './environment-context'; -export const RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION = '1.0.0'; +export const RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION = '2.0.0'; export type ResultDbSupplementalDisclosureClass = | 'aggregate' @@ -101,6 +101,14 @@ const pingCastleRules: DisclosureRule[] = [ } ]; +/** + * Exact counts, scores, functional levels and versions fingerprint the assessed environment + * (for example a single domain controller) and calibrate attack effort. Since policy 2.0.0 only + * binary configuration states cross the Result DB boundary; the other rules stay classified so + * their values remain internal instead of silently becoming unknown fields. + */ +const DISCLOSED_CLASSES = new Set(['configuration-state']); + const disclosureRules = new Map( pingCastleRules.map(rule => [`${rule.toolId}\u0000${rule.property}`, rule]) ); @@ -116,7 +124,7 @@ export function discloseSupplementalObservation( ): ResultDbSupplementalDisclosure | undefined { const toolId = observation.tool.id.trim().toLocaleLowerCase('en-US'); const rule = disclosureRules.get(`${toolId}\u0000${observation.property}`); - if (!rule) return undefined; + if (!rule || !DISCLOSED_CLASSES.has(rule.disclosureClass)) return undefined; const disclosedValue = rule.disclose(observation.normalizedValue ?? observation.originalValue); if (disclosedValue === undefined) return undefined; return { diff --git a/src/domain/result-db-trace.ts b/src/domain/result-db-trace.ts new file mode 100644 index 0000000..a0c31ab --- /dev/null +++ b/src/domain/result-db-trace.ts @@ -0,0 +1,52 @@ +/** + * Content-free redaction trace of one disclosed Result DB row (user decision 2026-09-28). + * It records which rule changed which field how often, and which fields were withheld; it never + * contains an original value, so it is disclosed, shown and exported together with the row. + */ +export interface ResultDbDisclosureTrace { + version: 1; + disclosurePolicyVersion: string; + identityGuardVersion: string; + /** Replacements per field and rule, for example `description` / `count` / 3. */ + redactions: Array<{ field: string; rule: string; count: number }>; + /** Internal fields that held a value but are never part of the Result DB (for example `policyActualValue`). */ + withheldFields: string[]; + /** Fields the identity guard withheld completely because a client identity survived in another form. */ + guardWithheldFields: string[]; +} + +const placeholderRules: Array<[RegExp, string]> = [ + [/\[count\]/g, 'count'], [/\[duration\]/g, 'duration'], [/\[date\]/g, 'date'], [/\[time\]/g, 'time'], + [/\[email\]/g, 'email'], [/\[sid\]/g, 'sid'], [/\[id\]|\[ID\]/g, 'identifier'], + [/\[directory-object\]/g, 'directory-object'], [/\[account\]/g, 'account'], [/\[network-path\]/g, 'network-path'], + [/\[path\]|\[PATH\]/g, 'path'], [/\[url\]/g, 'url'], [/\[ip\]/g, 'ip-address'], [/\[mac\]/g, 'mac-address'], + [/\[domain\]|\[DOMAIN\]/g, 'domain'], [/\[organization\]/g, 'organization'], [/\[redacted\]/g, 'private-value'], + [/\[(?:DOMAIN|SYSTEM)_PRINCIPAL_\d+\]/g, 'principal'], [/\[withheld by identity guard\]/g, 'identity-guard-withheld'] +]; + +function serialized(value: unknown): string { + if (value === undefined || value === null) return ''; + return typeof value === 'string' ? value : JSON.stringify(value); +} + +export function placeholderCounts(value: unknown): Map { + const text = serialized(value); + const counts = new Map(); + for (const [pattern, rule] of placeholderRules) { + const count = text.match(pattern)?.length ?? 0; + if (count) counts.set(rule, count); + } + return counts; +} + +/** + * Counts the placeholders a step added to one field. `before` is the field before the step, so + * placeholders that already existed (for example in the source text) are not attributed to it. + */ +export function addedPlaceholders(before: unknown, after: unknown, renameRule?: (rule: string) => string): Array<{ rule: string; count: number }> { + const previous = placeholderCounts(before); + return [...placeholderCounts(after)].flatMap(([rule, count]) => { + const added = count - (previous.get(rule) ?? 0); + return added > 0 ? [{ rule: renameRule ? renameRule(rule) : rule, count: added }] : []; + }); +} diff --git a/src/domain/tool-profile-definition.ts b/src/domain/tool-profile-definition.ts index d007938..7b6fdfb 100644 --- a/src/domain/tool-profile-definition.ts +++ b/src/domain/tool-profile-definition.ts @@ -227,6 +227,12 @@ export interface ToolProfileDefinition { firstRead?: { /** Concrete result statement of the scanner for this environment. */ resultHeaders?: RegExp[]; + /** + * Native result text whose first prose paragraph states the result (user decision 2026-09-28, + * Maester `ResultDetail`). The paragraph is shown verbatim; headings, tables and the rest of the + * field stay in Results. + */ + resultLeadHeaders?: RegExp[]; /** Explicit count of affected results. */ affectedHeaders?: RegExp[]; /** Count linked result-table rows when no explicit count exists. */ diff --git a/src/domain/unified-result.ts b/src/domain/unified-result.ts index 9dacf68..b8a9a8c 100644 --- a/src/domain/unified-result.ts +++ b/src/domain/unified-result.ts @@ -39,6 +39,7 @@ import { discloseSupplementalObservation, type ResultDbSupplementalDisclosureClass } from './result-db-supplemental-disclosure'; +import type { ResultDbDisclosureTrace } from './result-db-trace'; export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'policy-observation' | 'environment-observation'; export type UnifiedResultAudience = 'result-db' | 'internal-analysis'; @@ -174,7 +175,8 @@ export interface UnifiedResultProjection { scopeState?: 'known' | 'unknown'; effectivenessVerification?: 'verified' | 'not-verified'; scopeConfidence?: number; - reviewStatus: UnifiedReviewStatus; + /** Client-owned review state; internal analysis only, withheld by the Result DB disclosure policy. */ + reviewStatus?: UnifiedReviewStatus; reviewDisposition?: UnifiedReviewDisposition; reviewPriority?: ReviewPriority; reviewOwner?: string; @@ -185,6 +187,8 @@ export interface UnifiedResultProjection { createdAt: string; categories: UnifiedResultCategory[]; frameworkReferences: UnifiedResultFrameworkReference[]; + /** Result DB only: content-free redaction trace stored with the materialized row. */ + disclosureTrace?: ResultDbDisclosureTrace; } export interface UnifiedResultQuery { @@ -1154,8 +1158,6 @@ export function deriveUnifiedResultFacets(items: UnifiedResultProjection[], quer facet('mappingRuleVersion', 'Mapping Rule Set', item => item.mappingRuleVersion, 'mappingRuleVersion'), facet('sourceProfileVersion', 'Profile Version', item => item.sourceProfileVersion, 'sourceProfileVersion'), facet('processingStatus', 'Processing Status', item => item.processingStatus, 'processingStatus'), - facet('reviewStatus', 'Review Status', item => findingAxis(item) ? item.reviewStatus : undefined), - facet('reviewDisposition', 'Review Disposition', item => item.reviewDisposition, 'reviewDisposition'), facet('lifecycleState', 'Lifecycle', item => item.lifecycleState, 'lifecycleState'), facet('effectiveState', 'Effectiveness', item => item.effectiveState), categoryFacet() diff --git a/src/exports/unified-result-jsonl.ts b/src/exports/unified-result-jsonl.ts index a4999f9..94a3a8e 100644 --- a/src/exports/unified-result-jsonl.ts +++ b/src/exports/unified-result-jsonl.ts @@ -6,9 +6,21 @@ import type { import { discloseResultDbProjection, RESULT_DB_DISCLOSURE_POLICY_VERSION } from '../domain/result-db-disclosure'; export const RESULT_DB_JSONL_FORMAT = 'assessor-result-db-jsonl'; -export const RESULT_DB_JSONL_SCHEMA_VERSION = '2.0.0'; +export const RESULT_DB_JSONL_SCHEMA_VERSION = '3.0.0'; export const RESULT_DB_EXPORT_PAGE_SIZE = 1000; +/** + * The export is anonymized (no customer identity), not harmless: a deviation list still + * describes an exploitable security posture. Every file carries this handling marker. + */ +export const RESULT_DB_EXPORT_HANDLING = { + classification: 'confidential-security-posture', + notice: 'Anonymized security findings without environment-specific values: observed counts, dates and durations, ' + + 'customer configuration values, customer-specific principals and baseline releases are withheld. Public baseline ' + + 'expectations (including well-known Windows principals), vendor guidance and recommendation thresholds are ' + + 'retained. Share only within the assessment engagement and with approved processors.' +} as const; + type ResultPageReader = (query: UnifiedResultQuery) => Promise; function definedQueryEntries(query: UnifiedResultQuery): Array<[string, unknown]> { @@ -27,7 +39,7 @@ function exportScope(query: UnifiedResultQuery): Record { function exportFilters(query: UnifiedResultQuery): Record { const excluded = new Set([ 'projectId', 'assessmentId', 'assessmentIds', 'scanRunId', 'scanRunIds', 'evaluationRunId', - 'offset', 'limit', 'search', 'affectedAsset', 'observedFrom', 'observedTo' + 'offset', 'limit', 'search', 'affectedAsset', 'observedFrom', 'observedTo', 'reviewDisposition' ]); return Object.fromEntries(definedQueryEntries(query) .filter(([key]) => !excluded.has(key)) @@ -59,7 +71,8 @@ export function buildUnifiedResultJsonl( format: RESULT_DB_JSONL_FORMAT, schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION, disclosurePolicyVersion: RESULT_DB_DISCLOSURE_POLICY_VERSION, - generatedAt, + handling: RESULT_DB_EXPORT_HANDLING, + generatedOn: exportDate(generatedAt), resultCount: results.length, scope: exportScope(query), filters: exportFilters(query) @@ -71,9 +84,13 @@ export function buildUnifiedResultJsonl( return `${lines.map(line => JSON.stringify(line)).join('\n')}\n`; } +/** Only the calendar day travels with an export; a precise timestamp dates the assessment. */ +function exportDate(generatedAt: string): string { + return generatedAt.slice(0, 10); +} + export function unifiedResultJsonlFileName(generatedAt: string): string { - const timestamp = generatedAt.replace(/\.\d{3}Z$/, 'Z').replaceAll(':', '-'); - return `assessor-result-db-filtered-${timestamp}.jsonl`; + return `assessor-result-db-filtered-${exportDate(generatedAt)}.jsonl`; } export async function queryAllUnifiedResults( diff --git a/src/features/finding-reader/first-read.ts b/src/features/finding-reader/first-read.ts index a64ace4..baf7ca9 100644 --- a/src/features/finding-reader/first-read.ts +++ b/src/features/finding-reader/first-read.ts @@ -158,6 +158,32 @@ export function powerShellDisplayLines(command: string): string[] { return lines; } +/** + * First prose paragraph of a native result text, retained verbatim. Markdown headings, tables, + * rules and blank lines are skipped; nothing is shortened or rephrased. + */ +export function resultLead(value?: string): string | undefined { + if (!value?.trim()) return undefined; + // A bare status word ("Failed") is the status, not a result statement. + if (/^(?:passed|failed|skipped|error|investigate|not ?run|not ?tested)$/i.test(value.trim())) return undefined; + const lines: string[] = []; + for (const line of value.split(/\r?\n/)) { + // Exports that lost their line breaks keep the heading, table or next heading on the same line; + // the statement ends where such a structure begins. + const trimmed = line.trim().replace(/^#{1,6}\s+Test result\s+/i, ''); + const structure = /(?:^|\s)(?:\|\s|#{1,6}\s)/.exec(trimmed); + const text = (structure ? trimmed.slice(0, structure.index) : trimmed).trim(); + const structural = !text || /^[-*_]{3,}$/.test(text); + if (structural) { + if (lines.length) break; + continue; + } + lines.push(text); + if (structure) break; + } + return lines.join(' ') || undefined; +} + export function findingFirstRead( record: FindingRecord, toolId: string | undefined, linkedSheets: DatasetSheet[], outcome?: CanonicalOutcome ): FindingFirstRead | undefined { @@ -166,6 +192,15 @@ export function findingFirstRead( const rows: FirstReadRow[] = []; const result = fieldFor(record, definition.resultHeaders); if (result) rows.push({ label: 'Result', value: result.value.trim() }); + else { + // `Result` is a result statement only next to a separate status column (Maester export variants). + const hasSeparateStatus = record.fields.some(item => /^status$/i.test(normalize(item.label)) && item.value.trim()); + const lead = (definition.resultLeadHeaders ?? []) + .filter(pattern => hasSeparateStatus || !pattern.test('Result')) + .map(pattern => resultLead(fieldFor(record, [pattern])?.value)) + .find(Boolean); + if (lead) rows.push({ label: 'Result', value: lead }); + } const resultRows = linkedSheets.flatMap(sheet => sheet.analysis.records); if (definition.criterionEvidence) { diff --git a/src/features/results-browser/ResultsBrowser.tsx b/src/features/results-browser/ResultsBrowser.tsx index ae3da54..69b070e 100644 --- a/src/features/results-browser/ResultsBrowser.tsx +++ b/src/features/results-browser/ResultsBrowser.tsx @@ -9,10 +9,10 @@ import { browserDownloadAdapter } from '../../platform/browser/browser-download- import { downloadText } from '../../exports/text-download'; import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl'; import { hasSelectedTimeslotScope } from '../../app/work-scope'; -import { baselineVariantSummary } from '../../evaluation/policy-baseline-variants'; import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels'; import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog'; import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation'; +import type { ResultDbMaterializationStatus } from '../../domain/result-db-materialization'; const assessorApi = new AssessorApiClient(); const PAGE_SIZE = 50; @@ -24,12 +24,16 @@ interface ResultsBrowserProps { projectId?: string; scanRunIds?: string[]; assessmentIds?: string[]; + /** Work records holding the original of a row; the Result DB itself never shows originals. */ + workRecordIds?(row: UnifiedResultProjection): string[]; + onOpenInWork?(recordIds: string[]): void; } -export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmentIds }: ResultsBrowserProps) { +export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmentIds, workRecordIds, onOpenInWork }: ResultsBrowserProps) { const [filters, setFilters] = useState(() => ({ ...DEFAULT_RESULT_DB_FILTERS })); const [limit, setLimit] = useState(PAGE_SIZE); const [facetResult, setFacetResult] = useState(null); + const [resultDbStatus, setResultDbStatus] = useState(null); const [queryResult, setQueryResult] = useState(null); const [loading, setLoading] = useState(true); const [exporting, setExporting] = useState(false); @@ -59,6 +63,8 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen ]); setFacetResult(facets); setQueryResult(results); + // Read after the rows: serving them guarantees the stored Result DB is current. + setResultDbStatus(await assessorApi.getResultDbStatus().catch(() => null)); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Results could not be loaded.'); } finally { @@ -128,7 +134,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen Processing readiness: {processingWarnings.map(value => `${processingStatusLabel(value.value)} (${value.count.toLocaleString('en-US')})`).join(' · ')}. Comparisons with unknown or incompatible contracts are blocked; reimport the original source with the current profile where recommended. }
-

{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}

+

{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}{resultDbStatus && <>
{resultDbStatusText(resultDbStatus)}}

; } -export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: UnifiedResultProjection; expanded: boolean; onToggle(): void }) { +export function ResultRow({ item: sourceItem, expanded, onToggle, workRecordIds = [], onOpenInWork }: { + item: UnifiedResultProjection; + expanded: boolean; + onToggle(): void; + workRecordIds?: string[]; + onOpenInWork?(recordIds: string[]): void; +}) { const item = removeResultDbScopeIdentifiers(discloseResultDbProjection(sourceItem)); const environment = item.sourceKind === 'environment-observation'; const policyObservation = item.sourceKind === 'policy-observation'; @@ -166,9 +179,7 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif {contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'} {contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'} {resultSourceToolLabel(item.sourceTool)} - {processingStatusLabel(item.processingStatus)} - {environment ? '–' : item.reviewStatus} - + {processingStatusLabel(item.processingStatus)} {expanded &&
{environment &&
{item.environmentProperty} @@ -181,8 +192,6 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif {item.description &&

{item.description}

} {item.recommendation &&

Recommendation: {item.recommendation}

} {policyComparison &&
- Configured customer value -

{displayPolicyValue(item.policyActualValue)}

Expected baseline value

{displayPolicyValue(item.policyBaselineValue)}

} @@ -207,10 +216,10 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif
  • Baseline: {item.policyBaselineLabel}
  • Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}
  • {item.policyBaselineVariantAssessment ?
  • - {baselineVariantSummary(item.policyBaselineVariantAssessment)} + {disclosedBaselineDecision(item.policyBaselineVariantAssessment.decision)}

    No effective device state or GPO application is established by this backup comparison.

      {item.policyBaselineVariantAssessment.variants.map(variant =>
    • - Setting reference: {variant.settingId} · Value comparison: {variant.comparator.relation} + Value comparison: {variant.comparator.relation}

      Expected: {displayPolicyValue(variant.comparator.baseline)}

      Original baseline value: {displayPolicyValue(variant.baselineValues)}

    • )}
    @@ -223,6 +232,7 @@ export function ResultRow({ item: sourceItem, expanded, onToggle }: { item: Unif }
  • Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}
  • +
    }
    ; } @@ -239,6 +249,14 @@ function displayPolicyValue(value: unknown): string { return value === undefined ? '–' : formatPolicyComparisonValue(JSON.stringify(value)); } +/** Result DB collapses identical baseline expectations, so it states the decision without a variant count. */ +function disclosedBaselineDecision(decision: string): string { + if (decision === 'all-pass') return 'Matches every assessed baseline expectation (backup configuration only)'; + if (decision === 'all-fail') return 'Differs from every assessed baseline expectation (backup configuration only)'; + if (decision === 'mixed') return 'Review required: baseline expectations lead to different results'; + return 'Review required: comparison evidence or applicability is incomplete'; +} + function displayPolicyIdentity(item: UnifiedResultProjection): string | undefined { const identity = item.policyIdentityKey ?? item.sourceRuleId; return identity && !/^gpo-policy-identity-[0-9a-f]+$/i.test(identity) ? identity : undefined; @@ -251,3 +269,50 @@ function processingStatusLabel(value: string | undefined): string { if (value === 'incompatible') return 'Incompatible'; return 'Unknown'; } + +const traceFieldLabels: Record = { + title: 'Title', description: 'Description', recommendation: 'Recommendation', sourceRuleId: 'Source Rule ID', + policyBaselineLabel: 'Baseline', policyBaselineValue: 'Expected baseline value', + policyBaselineVariantAssessment: 'Baseline expectations', environmentNormalizedValue: 'Value', categories: 'Categories' +}; + +function resultDbStatusText(status: ResultDbMaterializationStatus): string { + if (status.state === 'not-built') return 'Result DB not built yet'; + const checked = status.materializedAt ? status.materializedAt.slice(0, 16).replace('T', ' ') : '–'; + return [ + status.state === 'current' ? `Checked ${checked} UTC` : `Outdated: checked ${checked} UTC, rebuilding`, + `policy ${status.disclosurePolicyVersion ?? '–'}`, + `identity guard ${status.identityGuardVersion ?? '–'}`, + `${status.rowCount.toLocaleString('en-US')} rows`, + `${status.redactionCount.toLocaleString('en-US')} redactions`, + `${status.guardWithheldRowCount.toLocaleString('en-US')} rows with withheld fields` + ].join(' · '); +} + +/** Content-free trace of the stored row: what was redacted and withheld, never the original. */ +function DisclosureTraceDetail({ item, workRecordIds, onOpenInWork }: { + item: UnifiedResultProjection; + workRecordIds: string[]; + onOpenInWork?(recordIds: string[]): void; +}) { + const trace = item.disclosureTrace; + if (!trace) return null; + const byField = new Map(); + for (const entry of trace.redactions) { + byField.set(entry.field, [...(byField.get(entry.field) ?? []), `${entry.rule} × ${entry.count.toLocaleString('en-US')}`]); + } + const hasOriginal = item.sourceKind === 'scanner-finding' || item.sourceKind === 'policy-evaluation'; + return
    + Checked content +
      +
    • Policy {trace.disclosurePolicyVersion} · identity guard {trace.identityGuardVersion}
    • + {[...byField].map(([field, rules]) =>
    • Redacted in {traceFieldLabels[field] ?? field}: {rules.join(', ')}
    • )} + {!byField.size &&
    • No redaction in the disclosed fields
    • } + {trace.withheldFields.length > 0 &&
    • Withheld fields: {trace.withheldFields.join(', ')}
    • } + {trace.guardWithheldFields.length > 0 &&
    • Withheld by identity guard: {trace.guardWithheldFields.map(field => traceFieldLabels[field] ?? field).join(', ')}
    • } +
    + {hasOriginal && (workRecordIds.length && onOpenInWork + ? + :

    The original is not part of the loaded Work scope.

    )} +
    ; +} diff --git a/src/features/results-browser/ResultsFilterPanel.tsx b/src/features/results-browser/ResultsFilterPanel.tsx index 2ae2113..cdc36aa 100644 --- a/src/features/results-browser/ResultsFilterPanel.tsx +++ b/src/features/results-browser/ResultsFilterPanel.tsx @@ -8,7 +8,7 @@ interface ResultsFilterPanelProps { } type MultiSelectFacetId = 'sourceKind' | 'sourceTool' | 'severity' | 'outcome' | 'technicalExecutionState' - | 'resultType' | 'processingStatus' | 'reviewDisposition' | 'lifecycleState' + | 'resultType' | 'processingStatus' | 'lifecycleState' | 'environmentState' | 'environmentProperty' | 'environmentSubjectType' | 'policyClassification'; const QUERY_KEY_BY_FACET: Record = { @@ -19,7 +19,6 @@ const QUERY_KEY_BY_FACET: Record = technicalExecutionState: 'technicalExecutionState', resultType: 'resultType', processingStatus: 'processingStatus', - reviewDisposition: 'reviewDisposition', lifecycleState: 'lifecycleState', environmentState: 'environmentState', environmentProperty: 'environmentProperty', @@ -27,7 +26,7 @@ const QUERY_KEY_BY_FACET: Record = policyClassification: 'policyClassification' }; -const READONLY_FACET_IDS = new Set(['reviewStatus', 'effectiveState']); +const READONLY_FACET_IDS = new Set(['effectiveState']); function toggleValue(current: string[], value: string): string[] | undefined { const set = new Set(current); diff --git a/src/features/results-browser/work-record-lookup.ts b/src/features/results-browser/work-record-lookup.ts new file mode 100644 index 0000000..f78584d --- /dev/null +++ b/src/features/results-browser/work-record-lookup.ts @@ -0,0 +1,26 @@ +import type { ImportedDataset } from '../../domain/dataset'; +import type { UnifiedResultProjection } from '../../domain/unified-result'; + +/** + * Work records holding the original of a Result DB row (user decision 2026-09-28: the Result DB shows + * only the checked content; the original stays in Work). Scanner rows match their finding occurrence; + * GPO comparison rows match the Work setting of the same scope and technical setting identity. + */ +export function workRecordIdsForResult(dataset: ImportedDataset | null | undefined, row: UnifiedResultProjection): string[] { + if (!dataset) return []; + const ids = new Set(); + for (const sheet of dataset.sheets) { + for (const record of sheet.analysis.records) { + if (row.sourceKind === 'scanner-finding' && record.findingOccurrenceId === row.sourceResultId) ids.add(record.id); + if (row.sourceKind === 'policy-evaluation' && row.sourceRuleId && record.id.startsWith('gpo-work-setting:')) { + try { + const key = JSON.parse(decodeURIComponent(record.id.slice('gpo-work-setting:'.length))) as unknown[]; + if (key[2] === row.policyScope && key[3] === row.sourceRuleId) ids.add(record.id); + } catch { + // Not an evaluated setting key; unevaluated diagnostics have no comparison original. + } + } + } + } + return [...ids]; +} diff --git a/src/persistence/assessor-repository.ts b/src/persistence/assessor-repository.ts index 842a55b..ad912ed 100644 --- a/src/persistence/assessor-repository.ts +++ b/src/persistence/assessor-repository.ts @@ -15,6 +15,7 @@ import type { } from '../domain/canonical-model'; import type { ReimportReviewTransferSummary } from '../domain/reimport-review-transfer'; import type { RiskAcceptanceCommand } from '../domain/risk-acceptance'; +import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization'; import type { LocalComparisonRule, ScanRunComparison } from '../domain/comparison'; import type { ImportFingerprint, ImportFingerprintInput } from '../domain/import-workflow'; import type { ImportArtifact, ImportProcessingRun, IngestionJob, IngestionJobClaim } from '../domain/ingestion-persistence'; @@ -210,6 +211,8 @@ export interface AssessorRepository { queryUnifiedResults(query?: UnifiedResultQuery): Promise; queryUnifiedResultFacets(query?: UnifiedResultQuery): Promise; getUnifiedResultById(resultProjectionId: string): Promise; + /** State of the materialized, disclosure-checked Result DB (see result-db-materialization). */ + getResultDbStatus(): Promise; listResultCategories(query?: UnifiedResultQuery): Promise; getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise; diff --git a/src/persistence/local-assessor-repository.ts b/src/persistence/local-assessor-repository.ts index 56fd1ea..4f6d596 100644 --- a/src/persistence/local-assessor-repository.ts +++ b/src/persistence/local-assessor-repository.ts @@ -59,6 +59,8 @@ import { type UnifiedResultQueryResult } from '../domain/unified-result'; import { discloseResultDbProjection, removeResultDbScopeIdentifiers } from '../domain/result-db-disclosure'; +import { buildResultDbIdentityGuards, type ResultDbIdentityGuard } from '../domain/result-db-identity-guard'; +import { isResultDbCurrent, materializeResultDb, type MaterializedResultDb, type ResultDbMaterializationStatus } from '../domain/result-db-materialization'; import { createPolicyConsistencyUnifiedResults } from '../evaluation/policy-consistency-results'; import type { AssessorRepository, @@ -294,6 +296,7 @@ class LocalImportSession implements ImportSession { export class LocalAssessorRepository implements AssessorRepository { private state = emptyState(); private stateRevision = 0; + private resultDb?: MaterializedResultDb; private readonly activeImportSessions = new Set(); get revision(): number { @@ -647,7 +650,42 @@ export class LocalAssessorRepository implements AssessorRepository { ? createPolicyConsistencyUnifiedResults(currentPolicyObservations) : []; const results = [...this.unifiedScannerResults(), ...environment, ...policyObservations, ...policyConsistency, ...policyEvaluations, ...policy]; - return audience === 'internal-analysis' ? results : results.map(discloseResultDbProjection); + if (audience === 'internal-analysis') return results; + return this.materializedResultDb(results, currentPolicyObservations).rows; + } + + /** + * In-memory counterpart of the stored Result DB: rebuilt once per state revision and served + * unchanged to every Result DB read until the state changes again. + */ + private materializedResultDb( + sourceResults: UnifiedResultProjection[], + currentPolicyObservations: UnifiedResultProjection[] + ): MaterializedResultDb { + if (this.resultDb && isResultDbCurrent(this.resultDb.status, this.stateRevision)) return this.resultDb; + const guards = this.identityGuards([...sourceResults, ...currentPolicyObservations]); + this.resultDb = materializeResultDb(sourceResults, guards, this.stateRevision, new Date().toISOString()); + return this.resultDb; + } + + async getResultDbStatus(): Promise { + const status = this.resultDb?.status; + if (!status) { + return { state: 'not-built', repositoryRevision: this.stateRevision, rowCount: 0, redactionCount: 0, guardWithheldRowCount: 0 }; + } + return clone({ ...status, repositoryRevision: this.stateRevision, + state: isResultDbCurrent(status, this.stateRevision) ? 'current' : 'outdated' }); + } + + /** Client identity guards from the complete, unfiltered internal state (see result-db-identity-guard). */ + private identityGuards(internalResults: UnifiedResultProjection[]): Map { + // Built on every read: the in-memory state is small, and a stale guard would fail open. + const environmentObservations = this.state.environmentObservations.flatMap(observation => { + const scanRun = this.state.scanRuns.find(item => item.id === observation.scanRunId); + const assessment = scanRun ? this.state.assessments.find(item => item.id === scanRun.assessmentId) : undefined; + return assessment ? [{ projectId: assessment.projectId, observation }] : []; + }); + return buildResultDbIdentityGuards({ projects: this.state.projects, environmentObservations, internalResults }); } async queryUnifiedResults(query: UnifiedResultQuery = {}): Promise { diff --git a/src/profiles/maester/maester-definition.ts b/src/profiles/maester/maester-definition.ts index ac91a97..f23b6ab 100644 --- a/src/profiles/maester/maester-definition.ts +++ b/src/profiles/maester/maester-definition.ts @@ -54,9 +54,11 @@ export const maesterDefinition: ToolProfileDefinition = { ], normalization: { severity: commonSeverityMappings, status: commonStatusMappings }, presentation: { - // The native test result stays in Results only (user decision 2026-09-25); the - // first read counts the rows Maester itself marks and links its documentation. + // User decision 2026-09-28 supersedes the Results-only rule of 2026-09-25 for the lead: the first + // prose paragraph of the native test result ("You have 3 of 16 users ...") is the problem statement, + // verbatim. Its tables and the complete text stay in Results; source-marked rows are still counted. firstRead: { + resultLeadHeaders: [/^resultdetail$/i, /^test result$/i, /^result$/i], statusRowCounts: true, nextStepLinkHeaders: [/^helpurl$/i], // Maester descriptions carry their remediation as a native `#### Remediation action:` subsection. diff --git a/src/profiles/public-search/catalog.json b/src/profiles/public-search/catalog.json index 4ec247c..8e896bc 100644 --- a/src/profiles/public-search/catalog.json +++ b/src/profiles/public-search/catalog.json @@ -6,7 +6,8 @@ "passwords": "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpsb/0b40db09-d95d-40a6-8467-32aedec8140c", "asr": "https://learn.microsoft.com/en-us/defender-endpoint/attack-surface-reduction-rules-overview", "maester": "https://maester.dev/docs/tests/maester/", - "pingcastle": "https://www.pingcastle.com/PingCastleFiles/ad_hc_rules_list.html" + "pingcastle": "https://www.pingcastle.com/PingCastleFiles/ad_hc_rules_list.html", + "microsoftIdentifiers": "https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions" }, "registryTargets": [ "SOFTWARE\\Policies\\Microsoft\\Windows Defender\\PUAProtection", @@ -757,5 +758,15 @@ "a8f5898e-1dc8-49a9-9878-85004b8a61e6", "92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b", "c1db55ab-c21a-4637-bb3f-a12568109d35" + ], + "microsoftIdentifiers": [ + "499b84ac-1321-427f-aa17-267ca6975798", + "797f4846-ba00-4fd7-ba43-dac1f8f63013", + "14d82eec-204b-4c2f-b7e8-296a70dab67e", + "de8bc8b5-d9f9-48b1-a8ad-b748da725064", + "62e90394-69f5-4237-9190-012177145e10", + "a0b1b346-4d3e-4e8b-98f8-753987be4970", + "10dae51f-b6af-4016-8d66-8c2a99b929b3", + "2af84b1e-32c8-42b7-82bc-daa82404023b" ] } diff --git a/src/styles/datahub.css b/src/styles/datahub.css index a5e64b2..e2baa31 100644 --- a/src/styles/datahub.css +++ b/src/styles/datahub.css @@ -367,10 +367,13 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select .client-info-card-details { display: grid; align-content: start; gap: 0; min-width: 0; padding: 0 var(--space-5) var(--space-3); } .client-info-card-extra { display: grid; align-content: start; min-width: 0; padding: 0 var(--space-5) var(--space-4); border-top: var(--border-hair) solid var(--line); } -/* Keep every environment tile on one readable track; overflow stays inside the segment. */ -.client-environment-grid { display: grid; grid-auto-flow: column; grid-auto-columns: min(var(--tile-double-compact-width), 100%); grid-template-rows: auto; align-content: start; justify-content: start; gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; } +/* Keep the six overview tiles in one row; narrower panels scroll horizontally. */ +.client-environment-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-environment-grid > .client-env-group, -.client-environment-grid > .client-os-inventory { display: flex; flex-direction: column; min-width: 0; width: min(100%, var(--tile-double-compact-width)); min-height: var(--tile-double-compact-width); 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); } +.client-environment-grid > .client-os-inventory { 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: hidden; } +@container environment-tile (max-width: 17rem) { + .client-env-facts > div:nth-child(n + 3) { display: none; } +} .client-environment-grid > section > header { align-items: flex-start; gap: var(--space-1); } .client-command-center .client-environment-grid > .client-os-inventory > header { margin-bottom: 0; } .client-environment-grid .client-environment-results-button { margin-top: 0; margin-inline-start: auto; } @@ -378,7 +381,6 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select .client-environment-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-environment-grid > section > .client-env-facts > div.is-wide { grid-column: 1 / -1; } .client-environment-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); } -.client-environment-grid > section > .client-env-facts dd { overflow-wrap: normal; word-break: normal; } .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); } .client-env-group { min-width: 0; } @@ -907,7 +909,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .results-browser-table { border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); width: 100%; overflow-x: auto; } .results-browser-row { display: grid; - grid-template-columns: minmax(280px, 1fr) 160px 80px 120px 140px 50px 90px 110px 150px 110px; + grid-template-columns: minmax(280px, 1fr) 160px 80px 120px 140px 50px 90px 110px 150px; gap: var(--legacy-space-3); align-items: center; padding: var(--legacy-space-2) var(--legacy-space-3); border-top: var(--border-hair) solid var(--line); font-size: var(--ui-control-size); min-width: fit-content; } .results-browser-row:first-child { border-top: 0; } @@ -927,6 +929,9 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .results-browser-row span[data-label="Source Rule ID"] { overflow: visible; white-space: normal; text-overflow: clip; line-height: 1.35; overflow-wrap: anywhere; } .results-browser-row-detail { grid-column: 1 / -1; min-width: 0; padding: var(--legacy-space-3); border-top: var(--border-hair) dashed var(--line); color: var(--muted); font-size: var(--ui-control-size); line-height: 1.55; overflow-wrap: anywhere; } .results-browser-row-detail ul { min-width: 0; margin: var(--legacy-space-2) 0 0; padding: 0; list-style: none; display: grid; gap: var(--legacy-space-1); } +.results-disclosure-trace { margin-top: var(--legacy-space-3); padding-top: var(--legacy-space-2); border-top: var(--border-hair) dashed var(--line); display: grid; gap: var(--legacy-space-2); justify-items: start; } +.results-disclosure-trace p { margin: 0; } +.results-db-status[data-state="outdated"], .results-db-status[data-state="not-built"] { color: var(--sev-medium); } .results-environment-value { color: var(--text); font-size: var(--fs-lg); line-height: var(--lh-prose); } .results-environment-value strong { color: var(--heading); } .results-environment-value p { margin: var(--legacy-space-1) 0 var(--legacy-space-3); overflow-wrap: anywhere; white-space: pre-wrap; } diff --git a/src/styles/functional-icons.css b/src/styles/functional-icons.css index 75bac70..daba7a4 100644 --- a/src/styles/functional-icons.css +++ b/src/styles/functional-icons.css @@ -26,7 +26,7 @@ Beschriftung liegen gemeinsam in derselben klick- und fokussierbaren Aktion. */ .tenant-back .triangle-icon { width: 28px; height: 28px; fill: var(--band-surface); } .db-structure-card-toggle { display: grid; place-items: center; width: 18px; height: 18px; } -.results-browser-title { display: flex; align-items: center; gap: 7px; } +.results-browser-title { display: flex; align-items: center; gap: 7px; text-align: left; } summary { display: flex; diff --git a/tests/app/app-version.test.ts b/tests/app/app-version.test.ts index ee10e00..c44cec8 100644 --- a/tests/app/app-version.test.ts +++ b/tests/app/app-version.test.ts @@ -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.8.7'); + expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.0'); expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';"); expect(app).toContain('aria-label="About ASSESSOR"'); expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';"); diff --git a/tests/domain/result-db-disclosure.test.ts b/tests/domain/result-db-disclosure.test.ts index db3d850..14756c6 100644 --- a/tests/domain/result-db-disclosure.test.ts +++ b/tests/domain/result-db-disclosure.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import type { UnifiedResultProjection } from '../../src/domain/unified-result'; -import { discloseResultDbProjection, removeResultDbScopeIdentifiers, RESULT_DB_DISCLOSURE_POLICY_VERSION } from '../../src/domain/result-db-disclosure'; +import { + discloseResultDbProjection, discloseResultDbProjectionWithTrace, removeResultDbScopeIdentifiers, RESULT_DB_DISCLOSURE_POLICY_VERSION +} from '../../src/domain/result-db-disclosure'; function unsafeScanner(): UnifiedResultProjection { return { @@ -41,6 +43,289 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => { } }); + it('withholds review status, disposition and priority from the Result DB projection', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), reviewDisposition: 'accepted', reviewPriority: 'high' + }); + + for (const key of ['reviewStatus', 'reviewDisposition', 'reviewPriority', 'reviewOwner', 'reviewDueDate', + 'sourceToolVersion', 'sourceObservedAt', 'affectedAsset', 'sourceLocator']) { + expect(Object.keys(disclosed)).not.toContain(key); + } + expect(discloseResultDbProjection(disclosed)).toEqual(disclosed); + }); + + it('replaces environment fingerprints in scanner prose but keeps public technical tokens', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'A-NotEnoughDC', + title: 'The number of DCs is too small to provide redundancy: 1 DC', + description: 'Last AD backup 55 days ago on 2026-08-04 at 13:37; built-in admin used 0 day(s) ago. ' + + '15 accounts and 1.015 objects, 3,250 computers, 42% of users, observed 28.09.2026.', + recommendation: 'Disable NTLMv1 and TLS 1.0 on Windows Server 2016 and Windows 10, see MT.1024, ' + + 'CVE-2021-34527, T1558.003, KB5005413, event ID 4769, port 445, 64-bit and S-1-5-32-544.' + }); + + expect(disclosed.title).toBe('The number of DCs is too small to provide redundancy: [count] DC'); + expect(disclosed.description).toBe('Last AD backup [duration] ago on [date] at [time]; built-in admin used [duration] ago. ' + + '[count] accounts and [count] objects, [count] computers, [count]% of users, observed [date].'); + expect(disclosed.recommendation).toBe('Disable NTLMv1 and TLS 1.0 on Windows Server 2016 and Windows 10, see MT.1024, ' + + 'CVE-2021-34527, T1558.003, KB5005413, event ID 4769, port 445, 64-bit and S-1-5-32-544.'); + }); + + it('keeps numbered remediation steps intact', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'purple-knight', title: 'Synthetic', + recommendation: '1. From the impacted resources, select 3 accounts.\n2) Remove the delegation.\n 10. Verify.' + }); + expect(disclosed.recommendation).toBe('1. From the impacted resources, select [count] accounts.\n2) Remove the delegation.\n10. Verify.'); + }); + + it('keeps inline ordered lists but not a lone count followed by a sentence', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'maester', title: 'Synthetic', + description: 'Open the admin center. 1. Select Users. 2. Choose 3 accounts. 3. Fill the form. Found 1. Next steps follow.', + recommendation: 'Enable the rules. 1) Outbound rules. 2) Network Restrictions: apply them.' + }); + expect(disclosed.description).toBe('Open the admin center. 1. Select Users. 2. Choose [count] accounts. 3. Fill the form. Found [count]. Next steps follow.'); + expect(disclosed.recommendation).toBe('Enable the rules. 1) Outbound rules. 2) Network Restrictions: apply them.'); + }); + + it('handles source quirks: repeated step numbers, markdown links showing their URL, code and sentence joins', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'maester', title: 'Navigate to the Microsoft 365 admin center', + description: '1. Navigate to Portal [https://portal.azure.com](https://portal.azure.com). 2. Search Entra. 3. Expand Manage. ' + + '3. On the Properties page continue. 4. Save. Open [blade](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/' + + 'RecommendationDetails.ReactView/id/x). Run (Get-Policy).keyRestrictions.isEnforced and System.DateTime ' + + 'or Windows.Storage with ASP.NET. It affects any endpoint.The indicator stays. See aka.ms/mfa and ' + + 'test.mysmartlogon.com; contoso.It remains.' + }); + expect(disclosed.title).toBe('Navigate to the Microsoft 365 admin center'); + expect(disclosed.description).toBe('1. Navigate to Portal [https://portal.azure.com](https://portal.azure.com). 2. Search Entra. 3. Expand Manage. ' + + '3. On the Properties page continue. 4. Save. Open [blade](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/' + + 'RecommendationDetails.ReactView/id/x). Run (Get-Policy).keyRestrictions.isEnforced and System.DateTime ' + + 'or Windows.Storage with ASP.NET. It affects any endpoint.The indicator stays. See aka.ms/mfa and ' + + '[domain]; [domain] remains.'); + }); + + it('keeps skipped step numbers, attribute flags and CISA section numbers', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'purple-knight', title: 'Synthetic', + description: '1. Create a group. 2. Open Entra. 3. Select Security. 3. Select Manage. 5. Under Admin, save. ' + + 'Accounts with adminCount=1 or the adminCount attribute set to 1 are protected; 4 accounts were found. ' + + 'See [CISA 4. Centralized Log Collection - MS.AAD.4.1v1].' + }); + expect(disclosed.description).toBe('1. Create a group. 2. Open Entra. 3. Select Security. 3. Select Manage. 5. Under Admin, save. ' + + 'Accounts with adminCount=1 or the adminCount attribute set to 1 are protected; [count] accounts were found. ' + + 'See [CISA 4. Centralized Log Collection - MS.AAD.4.1v1].'); + }); + + it('keeps the space before a generic domain suffix such as .onmicrosoft.com', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'maester', sourceRuleId: 'MT.1074', + title: 'No more than 100 outbound mails per day are sent using the .onmicrosoft.com domain', + description: 'Spam from \'.onmicrosoft.com\' addresses , and contoso.onmicrosoft.com stays hidden .' + }); + expect(disclosed.title).toBe('No more than [count] outbound mails per day are sent using the .onmicrosoft.com domain'); + expect(disclosed.description).toBe('Spam from \'.onmicrosoft.com\' addresses, and [domain] stays hidden.'); + }); + + it('keeps recommended target values in recommendations only', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'pingcastle', title: 'Synthetic', + description: 'The value is set to 3 on this system.', + recommendation: 'RequireIntegrity and RequireMutualAuthentication should be set to 1. LDAPOwnerModify configured to 0.' + }); + expect(disclosed.description).toBe('The value is set to [count] on this system.'); + expect(disclosed.recommendation).toBe('RequireIntegrity and RequireMutualAuthentication should be set to 1. LDAPOwnerModify configured to 0.'); + }); + + it('keeps documented vendor values in recommendations only, never an observed count beside them', () => { + const guidance = 'Set the ms-DS-MachineAccountQuota attribute on the domain NC head to 0 to disable it. ' + + 'It is recommended to set it to 1. Set it to zero (0). The expected new value is 00000000010000000002000000011. ' + + 'Add the flag by adding the number 1048576. Assign 1 as the Value; other values include 2 for Audit mode and 6 for Warn mode. ' + + 'Use get-adobject -ldapfilter "(admincount=1)" and a 20+ length password. It can be done through 2 methods. ' + + 'Note: only 10 will be listed below.'; + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'pingcastle', title: 'Synthetic', + description: guidance, recommendation: `${guidance} Currently 12 accounts are affected; set 4 of them to 0.` + }); + expect(disclosed.recommendation).toBe(`${guidance} Currently [count] accounts are affected; set [count] of them to [count].`); + expect(disclosed.description).not.toMatch(/(? { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'maester', title: 'Synthetic', + recommendation: 'See [Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity/overview) and ' + + 'https://github.com/cisagov/ScubaGear/blob/main/baselines/aad.md for MS.AAD.2.3 in the Entra admin center ' + + 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging?view=powershell-7 ' + + 'https://www.whitehouse.gov/wp-content/uploads/2022/01/M-22-09.pdf ' + + '(entra.microsoft.com). Grant Directory.ReadWrite.All and Policy.ReadWrite.AuthenticationMethod. ' + + 'Check system32\\logfiles\\firewall\\pfirewall.log. ' + + 'Tenant links https://contoso.sharepoint.com/sites/it, https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2, ' + + 'https://portal.azure.com/#@contoso.onmicrosoft.com/resource, https://learn.microsoft.com/?tenant=contoso.de, ' + + 'https://portal.azure.com/#blade/subscriptions/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b/resourceGroups/rg-contoso-prod/overview, ' + + 'https://entra.microsoft.com/#view/Apps/objectId/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b/appId/x ' + + 'and domains contoso.onmicrosoft.com, mail.contoso.de stay hidden.' + }); + expect(disclosed.recommendation).toBe('See [Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity/overview) and ' + + 'https://github.com/cisagov/ScubaGear/blob/main/baselines/aad.md for MS.AAD.2.3 in the Entra admin center ' + + 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging?view=powershell-7 ' + + 'https://www.whitehouse.gov/wp-content/uploads/2022/01/M-22-09.pdf ' + + '(entra.microsoft.com). Grant Directory.ReadWrite.All and Policy.ReadWrite.AuthenticationMethod. ' + + 'Check system32\\logfiles\\firewall\\pfirewall.log. ' + + 'Tenant links [url], [url], [url], [url], [url], [url] ' + + 'and domains [domain], [domain] stay hidden.'); + }); + + it('keeps vendor thresholds in recommendations but not in observed titles or descriptions', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'pingcastle', title: 'Password length is at least 7 characters for 15 accounts', + description: 'Observed: at least 7 characters on 15 accounts inactive for more than 55 days.', + recommendation: 'A strong password is at least 8 characters, rotated every 180 days, removed when older than 90 days, ' + + 'with 15 or more characters for service accounts and no more than 5 attempts; 3 accounts are affected.' + }); + expect(disclosed.title).toBe('Password length is at least [count] characters for [count] accounts'); + expect(disclosed.description).toBe('Observed: at least [count] characters on [count] accounts inactive for more than [duration].'); + expect(disclosed.recommendation).toBe('A strong password is at least 8 characters, rotated every 180 days, removed when older than 90 days, ' + + 'with 15 or more characters for service accounts and no more than 5 attempts; [count] accounts are affected.'); + }); + + it('keeps public policy and registry paths and documented authorities instead of turning them into accounts', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '2.3.10.7', + recommendation: 'Set the following UI path to Enabled:\n' + + 'Computer Configuration\\Policies\\Windows Settings\\Security Settings\\Local Policies\\Security Options\\Network access: Remotely accessible registry paths\n' + + 'The value is stored in HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecurePipeServers\\Winreg\\AllowedExactPaths:Machine and ' + + 'System\\CurrentControlSet\\Control\\ProductOptions.\n' + + 'Grant BUILTIN\\Administrators, NT SERVICE\\WdiServiceHost and RESTRICTED SERVICES\\PrintSpoolerService.\n' + + 'Do not grant CONTOSO\\jane or CONTOSO\\svc-backup.' + }); + expect(disclosed.recommendation).toBe('Set the following UI path to Enabled:\n' + + 'Computer Configuration\\Policies\\Windows Settings\\Security Settings\\Local Policies\\Security Options\\Network access: Remotely accessible registry paths\n' + + 'The value is stored in HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecurePipeServers\\Winreg\\AllowedExactPaths:Machine and ' + + 'System\\CurrentControlSet\\Control\\ProductOptions.\n' + + 'Grant BUILTIN\\Administrators, NT SERVICE\\WdiServiceHost and RESTRICTED SERVICES\\PrintSpoolerService.\n' + + 'Do not grant [account] or [account].'); + }); + + it('keeps Group Policy node paths, hard-wrapped registry paths, rule references and documented address ranges', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '2.3.10.9', + description: 'Use the built-in security template of Group Policy (e.g. Windows Settings\\Security Settings). ' + + 'It depends on Network security: LAN Manager Authentication Level (Rule 2.3.11.7).', + recommendation: 'System\\CurrentControlSet\\Control\\Terminal Server System\\CurrentControlSet\\Control\\Terminal\n' + + 'Server\\UserConfig System\\CurrentControlSet\\Control\\ContentIndex.\nCONTOSO\\jane must not be listed.' + }); + expect(disclosed.description).toBe('Use the built-in security template of Group Policy (e.g. Windows Settings\\Security Settings). ' + + 'It depends on Network security: LAN Manager Authentication Level (Rule 2.3.11.7).'); + expect(disclosed.recommendation).toBe('System\\CurrentControlSet\\Control\\Terminal Server System\\CurrentControlSet\\Control\\Terminal\n' + + 'Server\\UserConfig System\\CurrentControlSet\\Control\\ContentIndex.\n[account] must not be listed.'); + + const firewall = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'S-FirewallScript', title: 'Synthetic', + recommendation: 'Allow local network access: 10.0.0.0 to 10.255.255.255, 192.168.0.0 to 192.168.255.255; ' + + 'block 0.0.0.0 to 9.255.255.255 11.0.0.0 to 126.255.255.255 128.0.0.1 to 172.15.255.255 172.32.0.0 to 192.167.255.255 ' + + '192.169.0.0 to 255.255.255.255; host 10.20.30.40 and subnet 10.20.30.0 to 10.20.30.255 observed.' + }); + expect(firewall.recommendation).toBe('Allow local network access: 10.0.0.0 to 10.255.255.255, 192.168.0.0 to 192.168.255.255; ' + + 'block 0.0.0.0 to 9.255.255.255 11.0.0.0 to 126.255.255.255 128.0.0.1 to 172.15.255.255 172.32.0.0 to 192.167.255.255 ' + + '192.169.0.0 to 255.255.255.255; ' + + 'host [ip] and subnet [ip] to [ip] observed.'); + }); + + it('keeps multi-part CIS numbers in titles and redacts only standalone IPv4 addresses', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '18.9.20.1.1', + title: "18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'", + description: 'Rule 18.9.20.1 applies; the server 10.20.30.40 and 999.1.1.1 were observed.' + }); + expect(disclosed.title).toBe("18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'"); + expect(disclosed.description).toBe('Rule 18.9.20.1 applies; the server [ip] and 999.1.1.1 were observed.'); + }); + + it('replaces compact durations such as 24h or 30d', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'pingcastle', title: 'Synthetic', description: 'Rotated 30d ago, checked every 24h, 5min, 2yrs.' + }); + expect(disclosed.description).toBe('Rotated [duration] ago, checked every [duration], [duration], [duration].'); + }); + + it('keeps registry-path GPO titles readable instead of treating them as DOMAIN\\account pairs', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', + title: 'Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators' + }); + expect(disclosed.title).toBe('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators'); + }); + + it('keeps documented Microsoft principals in the Soll value readable', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', title: 'Privilege Rights · SeServiceLogonRight', + policyBaselineValue: { kind: 'principal-set', principals: ['*S-1-5-113', '*S-1-5-83-0', 'S-1-5-9', + 'NT SERVICE\\WdiServiceHost', 'NT VIRTUAL MACHINE\\Virtual Machines', 'Window Manager\\Window Manager Group', + 'CONTOSO\\svc-backup', 'S-1-5-21-1-2-3-1105'] } + }); + expect(disclosed.policyBaselineValue).toEqual({ kind: 'principal-set', principals: ['*S-1-5-113', '*S-1-5-83-0', 'S-1-5-9', + 'NT SERVICE\\WdiServiceHost', 'NT VIRTUAL MACHINE\\Virtual Machines', 'Window Manager\\Window Manager Group', + '[DOMAIN_PRINCIPAL_1]', 'S-1-5-21-[DOMAIN]-1105'] }); + }); + + it('keeps public benchmark values in CIS-CAT prose', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '1.1.4', + title: "(L1) Ensure 'Minimum password length' is set to '14 or more character(s)'", + recommendation: "Set 'Account lockout duration' to 15 or more minute(s)." + }); + + expect(disclosed.title).toBe("(L1) Ensure 'Minimum password length' is set to '14 or more character(s)'"); + expect(disclosed.recommendation).toBe("Set 'Account lockout duration' to 15 or more minute(s)."); + }); + + it('never discloses an unprotected number from non-benchmark scanner prose', () => { + const fingerprints = ['7 DC', '15 accounts', '55 days', '0 day(s) ago', '2026-08-04', '13:37', '1.015', '3,250']; + for (const tool of ['pingcastle', 'purple-knight', 'maester']) { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: tool, title: `Synthetic ${fingerprints.join(', ')}`, + description: fingerprints.join(' / '), recommendation: `Review ${fingerprints.join(' and ')}` + }); + const prose = [disclosed.title, disclosed.description, disclosed.recommendation].join('\n'); + expect(prose.replace(/\[(?:count|duration|date|time)\]/g, '')).not.toMatch(/\d/); + } + }); + + it('discloses only the baseline family instead of the compared release', () => { + for (const [label, family] of [ + ['Microsoft Security Baseline Windows Server 2025 (2602)', 'Microsoft Security Baseline - Windows Server'], + ['Windows 11 25H2 Security Baseline', 'Microsoft Security Baseline - Windows Client'], + ['Microsoft 365 Apps for enterprise 2512', 'Microsoft Security Baseline - Microsoft 365 Apps'], + ['Private customer template', 'Baseline recommendation'] + ]) { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', title: 'Synthetic setting', + policyBaselineLabel: label + }); + expect(disclosed.policyBaselineLabel).toBe(family); + } + }); + + it('collapses identical baseline expectations so the compared release count is not disclosed', () => { + const variant = (id: string) => ({ settingId: id, baselineValues: [], comparator: { + version: 1 as const, comparator: { kind: 'equality' as const }, relation: 'different' as const, + actual: { kind: 'text' as const, value: '0' }, baseline: { kind: 'text' as const, value: '1' }, diagnosticCodes: [] + } }); + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', title: 'Synthetic setting', + policySourceCount: 4, + policyBaselineVariantAssessment: { version: 1, scope: 'backup-configuration', decision: 'all-fail', + actualValues: [], variants: [variant('server-2019'), variant('server-2022'), variant('server-2025')], diagnosticCodes: [] } + }); + + expect(disclosed.policyBaselineVariantAssessment?.variants).toHaveLength(1); + expect(disclosed).not.toHaveProperty('policySourceCount'); + expect(JSON.stringify(disclosed)).not.toMatch(/server-20\d\d/); + }); + it('retains Purple Knight native rule identity and remediation text', () => { const disclosed = discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'purple-knight', sourceRuleId: 'SI000055', @@ -112,7 +397,7 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => { expect(disclosed.recommendation).not.toMatch(/CONTOSO|jane|10\.20\.30\.40/); }); - it('keeps GPO Ist and Soll values while consistently redacting a non-public system SID', () => { + it('keeps only the GPO Soll value and withholds every customer value and principal', () => { const systemSid = 'S-1-5-82-123456789-234567890-345678901-456789012-567890123'; const disclosed = discloseResultDbProjection({ ...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', @@ -130,14 +415,18 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => { } }); - expect(disclosed.policyActualValue).toMatchObject({ kind: 'text', value: '5', - principals: ['[SYSTEM_PRINCIPAL_1]', 'S-1-5-32-544'] }); + expect(disclosed).not.toHaveProperty('policyActualValue'); expect(disclosed.policyBaselineValue).toMatchObject({ kind: 'text', value: '10' }); - expect(JSON.stringify(disclosed)).not.toContain(systemSid); - expect(JSON.stringify(disclosed).match(/\[SYSTEM_PRINCIPAL_1\]/g)).toHaveLength(3); + expect(disclosed.policyBaselineVariantAssessment).toMatchObject({ decision: 'all-fail', actualValues: [], + variants: [{ comparator: { relation: 'different', baseline: { kind: 'string-set', values: ['S-1-5-32-544'] } } }] }); + expect(disclosed.policyBaselineVariantAssessment?.variants[0].comparator).not.toHaveProperty('actual'); + const serialized = JSON.stringify(disclosed); + expect(serialized).not.toContain(systemSid); + expect(serialized).not.toContain('PRINCIPAL'); + expect(serialized).not.toContain('"5"'); }); - it('keeps the complete technical GPO deviation without customer or source identity', () => { + it('keeps the technical GPO setting, Soll value and relation without customer values or source identity', () => { const disclosed = discloseResultDbProjection({ ...unsafeScanner(), sourceKind: 'policy-evaluation', sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators', @@ -177,28 +466,24 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => { expect(disclosed).toMatchObject({ title: 'Restrict driver installation to administrators', sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators', - policyActualValue: { - kind: 'registry-policy', operation: 'set-value', key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers\\PointAndPrint', - valueName: 'RestrictDriverInstallationToAdministrators', typeCode: 4, data: { kind: 'uint32', value: 0 }, - principals: ['S-1-5-18', 'S-1-5-32-544', 'BUILTIN\\Administrators', '[DOMAIN]\\Domain Admins', - '[DOMAIN_PRINCIPAL_1]', 'S-1-5-21-[DOMAIN]-512'] - }, policyBaselineValue: { data: { kind: 'uint32', value: 1 } }, - policyBaselineLabel: 'Microsoft Security Baseline Windows Server 2022 v1', + policyBaselineLabel: 'Microsoft Security Baseline - Windows Server', + policyComparisonRelation: 'different', recommendation: 'Set RestrictDriverInstallationToAdministrators to 1.', policyDiagnosticCodes: ['policy-safe-code'], policyBaselineVariantAssessment: { - variants: [{ settingId: 'baseline-variant-1', comparator: { - actual: { kind: 'string-set', values: ['[DOMAIN_PRINCIPAL_2]'] }, - baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] } + actualValues: [], + variants: [{ settingId: 'baseline-expectation-1', comparator: { + relation: 'different', baseline: { kind: 'string-set', values: ['BUILTIN\\Administrators'] } }}] } }); + expect(disclosed).not.toHaveProperty('policyActualValue'); expect(disclosed.policyIdentityKey).toBeUndefined(); expect(disclosed.policyBaselineId).toBeUndefined(); const serialized = JSON.stringify(disclosed); - for (const privateValue of ['Private Company', 'dummy.local', 'DUMMY – Domain Controllers Security', - 'A1B2C3D4', '99999999', 'fileserver', 'jane.doe', '123456789', '234567890', '345678901']) { + for (const privateValue of ['Private Company', 'dummy.local', 'DUMMY', 'Domain Admins', 'PRINCIPAL', '2022', + 'A1B2C3D4', '99999999', 'fileserver', 'jane.doe', 'named.user', '123456789', '234567890', '345678901']) { expect(serialized).not.toContain(privateValue); } expect(serialized).toContain('Backup comparison; effective endpoint state not verified'); @@ -220,4 +505,63 @@ describe(`Result DB disclosure ${RESULT_DB_DISCLOSURE_POLICY_VERSION}`, () => { scanRunId: 'scoped-scan' }); }); + + it('keeps the complete seven-level CIS number so sibling recommendations do not collide', () => { + const first = discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '18.10.42.11.1.1.1' }); + const second = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: 'xccdf_org.cisecurity.benchmarks_rule_18.10.42.11.1.1.2_L1_Ensure' + }); + expect(first.sourceRuleId).toBe('18.10.42.11.1.1.1'); + expect(second.sourceRuleId).toBe('18.10.42.11.1.1.2'); + expect(discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'cis-cat', sourceRuleId: '1.2.3.4.5.6.7.8.9.10' }).sourceRuleId) + .toBeUndefined(); + }); + + it('does not mistake the ordinary word "limited" for an organization but still redacts legal names', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), title: 'Limited number of Global Admins are assigned.', + description: 'External sharing limited to existing guests.', recommendation: 'Contact Contoso Ltd or Fabrikam GmbH.' + }); + expect(disclosed.title).toBe('Limited number of Global Admins are assigned.'); + expect(disclosed.description).toBe('External sharing limited to existing guests.'); + expect(disclosed.recommendation).not.toMatch(/Contoso|Fabrikam/); + expect(disclosed.recommendation).toContain('[organization]'); + }); + + it('keeps public versions, releases and step, option and page references in scanner prose', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), sourceTool: 'pingcastle', sourceRuleId: 'A-DCLdapsProtocolAdvanced', + title: 'At least one DC is using Tls1.0 or Tls1.1.', + description: 'NTLMv2 works starting Windows 95. See Microsoft Graph v1.0. If using a pre 2012-R2 schema, ' + + 'divide the value from step 2 by the value from step 3. Option 1 is described on Page 16. 4 DCs were observed.' + }); + expect(disclosed.title).toBe('At least one DC is using Tls1.0 or Tls1.1.'); + expect(disclosed.description).toBe('NTLMv2 works starting Windows 95. See Microsoft Graph v1.0. If using a pre 2012-R2 schema, ' + + 'divide the value from step 2 by the value from step 3. Option 1 is described on Page 16. [count] DCs were observed.'); + }); + + it('keeps documented Microsoft GUIDs but redacts every other GUID', () => { + const disclosed = discloseResultDbProjection({ + ...unsafeScanner(), + description: 'Add Azure DevOps (App ID: 499b84ac-1321-427f-aa17-267ca6975798) and block with ASR rule ' + + 'd3e037e1-3eb8-44c8-a917-57927947596d. Tenant 11111111-2222-4333-8444-555555555555 is affected.' + }); + expect(disclosed.description).toBe('Add Azure DevOps (App ID: 499b84ac-1321-427f-aa17-267ca6975798) and block with ASR rule ' + + 'd3e037e1-3eb8-44c8-a917-57927947596d. Tenant [id] is affected.'); + }); + + it('withholds per-finding scanner scores', () => { + const disclosed = discloseResultDbProjection({ ...unsafeScanner(), sourceTool: 'purple-knight', sourceRuleId: 'SI000011', score: 73 }); + expect(disclosed).not.toHaveProperty('score'); + }); + + it('lists withheld fields in the trace without revealing client review state', () => { + const reviewed = discloseResultDbProjectionWithTrace({ ...unsafeScanner(), reviewDisposition: 'accepted', reviewPriority: 'high' }); + const unreviewed = discloseResultDbProjectionWithTrace({ + ...unsafeScanner(), reviewStatus: undefined, reviewOwner: undefined, reviewDueDate: undefined + }); + expect(reviewed.disclosureTrace?.withheldFields).toEqual(unreviewed.disclosureTrace?.withheldFields); + expect(JSON.stringify(reviewed.disclosureTrace)).not.toMatch(/review/i); + expect(reviewed.disclosureTrace?.withheldFields).toContain('affectedAsset'); + }); }); diff --git a/tests/domain/result-db-identity-guard.test.ts b/tests/domain/result-db-identity-guard.test.ts new file mode 100644 index 0000000..edc763b --- /dev/null +++ b/tests/domain/result-db-identity-guard.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import type { AssessmentProject } from '../../src/domain/canonical-model'; +import type { EnvironmentObservation } from '../../src/domain/environment-context'; +import type { UnifiedResultProjection } from '../../src/domain/unified-result'; +import { + buildResultDbIdentityGuards, + collectResultDbIdentityLiterals, + ResultDbIdentityGuard +} from '../../src/domain/result-db-identity-guard'; +import { discloseResultDbProjection, discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure'; + +const project: AssessmentProject = { + id: 'synthetic-project', name: 'Westeros Holding', createdAt: '2026-09-28T00:00:00Z', updatedAt: '2026-09-28T00:00:00Z', + attributes: { shortName: 'WSTR' } +}; + +function observation(property: string, value: string, identity?: string): EnvironmentObservation { + return { + version: 1, id: `obs-${property}`, scanRunId: 'run', importFileId: 'file', + tool: { id: 'pingcastle', name: 'PingCastle' }, subject: { type: 'domain-information', identity }, + property, label: property, originalValue: value, normalizedValue: value, valueType: 'string', state: 'observed', + source: { objectPath: 'synthetic' }, mappingVersion: 'synthetic', attributes: {} + }; +} + +function scanner(overrides: Partial = {}): UnifiedResultProjection { + return { + resultProjectionId: 'scanner-finding:occ-1', sourceKind: 'scanner-finding', sourceResultId: 'occ-1', + projectId: project.id, assessmentId: 'timeslot-7f3a', collectionRunId: 'scan-run-7f3a', scanRunId: 'scan-run-7f3a', sourceTool: 'pingcastle', + sourceRuleId: 'A-DC-Spooler', title: 'Spooler is running on DC01', description: 'Host DC01 in sevenkingdoms.local exposes it.', + recommendation: 'Disable the spooler on WINTERFELL and ask Jon Snow.', affectedAsset: 'DC01', + severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail', + technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true, + lifecycleState: 'unknown', mappingConfidence: 1, reviewOwner: 'Jon Snow', importedAt: '2026-09-28T00:00:00Z', + createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: [], + ...overrides + }; +} + +describe('Result DB client identity guard', () => { + it('collects the identities a client names in its own data, without generic or public words', () => { + const literals = collectResultDbIdentityLiterals({ + projects: [project], + environmentObservations: [observation('domain.fqdn', 'sevenkingdoms.local'), observation('host.name', 'WINTERFELL', 'WINTERFELL'), + observation('domain.functional_level', 'Windows Server 2016')], + affectedAssets: ['DC01', 'Domain Admins', 'krbtgt'], + reviewOwners: ['Jon Snow'], + principals: ['SEVENKINGDOMS\\arya.stark', 'BUILTIN\\Administrators', 'ned@sevenkingdoms.local'] + }); + // Case-insensitive: the NetBIOS name SEVENKINGDOMS and the domain label sevenkingdoms are one literal. + expect(literals.map(value => value.toLowerCase())).toEqual(expect.arrayContaining(['westeros holding', 'wstr', + 'sevenkingdoms.local', 'sevenkingdoms', 'winterfell', 'dc01', 'jon snow', 'arya.stark', 'ned'])); + expect(literals.map(value => value.toLowerCase())).not.toEqual(expect.arrayContaining(['domain admins', 'krbtgt', 'builtin', + 'administrators', 'windows server 2016'])); + expect(literals.indexOf('sevenkingdoms.local')).toBeLessThan(literals.indexOf('sevenkingdoms')); + }); + + it('removes names that no pattern recognizes, such as DC01 or a person, from every disclosed field', () => { + const guards = buildResultDbIdentityGuards({ + projects: [project], + environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }], + internalResults: [scanner()] + }); + const disclosed = discloseResultDbProjection(scanner(), guards.get(project.id)); + const serialized = JSON.stringify(disclosed); + for (const identity of ['DC01', 'WINTERFELL', 'Jon Snow', 'sevenkingdoms']) expect(serialized).not.toContain(identity); + expect(disclosed.title).toBe('Spooler is running on [redacted]'); + }); + + it('takes only account forms from GPO values, never registry keys or paths', () => { + const guards = buildResultDbIdentityGuards({ + projects: [project], environmentObservations: [], + internalResults: [scanner({ policyValues: [{ key: 'Software\\Policies\\Microsoft\\Windows NT\\Printers', valueName: 'Machine\\Test', + principals: ['SEVENKINGDOMS\\arya.stark', 'HKLM\\Software', 'BUILTIN\\Administrators'] }] })] + }); + const guard = guards.get(project.id)!; + expect(guard.text('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators')) + .toBe('Software\\Policies\\Microsoft\\Windows NT\\Printers · RestrictDriverInstallationToAdministrators'); + expect(guard.text('Granted to arya.stark in SEVENKINGDOMS')).toBe('Granted to [redacted] in [redacted]'); + }); + + it('withholds a whole field when an identity survives in another spelling', () => { + const guard = new ResultDbIdentityGuard(['sevenkingdoms.local']); + expect(guard.text('Forest seven-kingdoms . local is affected')).toBe('[withheld by identity guard]'); + expect(guard.text('Forest sevenkingdoms.local is affected')).toBe('Forest [redacted] is affected'); + expect(guard.text('Nothing identifying here')).toBe('Nothing identifying here'); + }); + + it('records a content-free trace for every stored row', () => { + const guards = buildResultDbIdentityGuards({ + projects: [project], environmentObservations: [{ projectId: project.id, observation: observation('host.name', 'WINTERFELL') }], + internalResults: [scanner()] + }); + const row = discloseResultDbProjectionWithTrace(scanner({ + description: 'Host DC01 has 15 accounts, see https://tenant.sharepoint.com/x.', reviewStatus: 'open' + }), guards.get(project.id)); + expect(row.disclosureTrace).toMatchObject({ version: 1, guardWithheldFields: [] }); + // DC01 is this finding's own affected asset (private-value); Jon Snow is caught by the client guard. + expect(row.disclosureTrace?.redactions).toEqual(expect.arrayContaining([ + { field: 'title', rule: 'private-value', count: 1 }, + { field: 'description', rule: 'count', count: 1 }, + { field: 'description', rule: 'url', count: 1 }, + { field: 'description', rule: 'private-value', count: 1 }, + { field: 'recommendation', rule: 'private-value', count: 1 }, + { field: 'recommendation', rule: 'identity-guard', count: 1 } + ])); + expect(row.disclosureTrace?.withheldFields).toContain('affectedAsset'); + // Client-owned review state is withheld without a trace entry, so its presence is not revealed. + expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/review/i); + expect(JSON.stringify(row.disclosureTrace)).not.toMatch(/DC01|WINTERFELL|Jon Snow|15|sharepoint/); + expect(row.recommendation).toBe('Disable the spooler on [redacted] and ask [redacted].'); + }); + + it('is idempotent: disclosing a stored row again changes nothing, so UI, API and export show the same content', () => { + const guards = buildResultDbIdentityGuards({ projects: [project], environmentObservations: [], internalResults: [scanner()] }); + const fixtures = [ + scanner(), + scanner({ sourceTool: 'maester', sourceRuleId: 'MT.1074', title: 'No more than 100 mails via the .onmicrosoft.com domain', + description: '1. Open [Entra](https://entra.microsoft.com). 2. Select Users. 3. Save. Found 4 accounts.', + recommendation: 'Set at least 8 characters; see https://learn.microsoft.com/en-us/entra/ and Directory.ReadWrite.All.' }), + scanner({ sourceTool: 'cis-cat', sourceRuleId: '18.9.20.1.1', + title: "18.9.20.1.1 (L2) Ensure 'Turn off downloading of print drivers over HTTP' is set to 'Enabled'", + recommendation: 'Computer Configuration\\Policies\\Administrative Templates\\System\\Internet Communication Management' }) + ]; + for (const fixture of fixtures) { + const stored = discloseResultDbProjectionWithTrace(fixture, guards.get(project.id)); + expect(discloseResultDbProjection(stored)).toEqual(stored); + } + }); +}); diff --git a/tests/domain/result-db-supplemental-disclosure.test.ts b/tests/domain/result-db-supplemental-disclosure.test.ts index 8d1ed8b..dee3ecf 100644 --- a/tests/domain/result-db-supplemental-disclosure.test.ts +++ b/tests/domain/result-db-supplemental-disclosure.test.ts @@ -28,16 +28,19 @@ function observation(overrides: Partial = {}): Environme describe('Result DB supplemental disclosure boundary', () => { it('rebuilds an approved value from controlled metadata without carrying source identity or raw context', () => { - const disclosed = discloseSupplementalObservation(observation()); + const disclosed = discloseSupplementalObservation(observation({ + property: 'domain.recycle_bin_enabled', normalizedValue: true, originalValue: 'Enabled for Private Customer', + valueType: 'boolean' + })); expect(disclosed).toEqual({ policyVersion: RESULT_DB_SUPPLEMENTAL_DISCLOSURE_POLICY_VERSION, - disclosureClass: 'technical-metadata', - property: 'domain.functional_level', - title: 'Domain functional level', + disclosureClass: 'configuration-state', + property: 'domain.recycle_bin_enabled', + title: 'AD Recycle Bin enabled', subjectType: 'active-directory-environment', - value: 2016, - valueType: 'number', + value: true, + valueType: 'boolean', state: 'observed' }); expect(JSON.stringify(disclosed)).not.toMatch(/Private Customer|private\.synthetic|customer\/path/i); @@ -58,10 +61,15 @@ describe('Result DB supplemental disclosure boundary', () => { }))).toBeUndefined(); }); - it('allows bounded aggregate and boolean configuration values only', () => { - expect(discloseSupplementalObservation(observation({ - property: 'risk.domain', normalizedValue: 42, originalValue: 'Private score 42' - }))).toMatchObject({ value: 42, disclosureClass: 'aggregate' }); + it('withholds counts, scores, levels and versions that fingerprint the environment', () => { + for (const [property, value] of [['risk.domain', 42], ['domain.controller_count', 1], ['domain.functional_level', 2016], + ['forest.schema_version', 88], ['kerberos.krbtgt.version', 3], ['report.engine_version', '3.3.0.0'], + ['logging.wef.configuration_count', 0]] as const) { + expect(discloseSupplementalObservation(observation({ property, normalizedValue: value }))).toBeUndefined(); + } + }); + + it('allows bounded boolean configuration values only', () => { expect(discloseSupplementalObservation(observation({ property: 'domain.recycle_bin_enabled', normalizedValue: true, originalValue: 'Private enabled', valueType: 'boolean' }))).toMatchObject({ value: true, disclosureClass: 'configuration-state' }); diff --git a/tests/exports/unified-result-jsonl.test.ts b/tests/exports/unified-result-jsonl.test.ts index 224f2c5..7ce1739 100644 --- a/tests/exports/unified-result-jsonl.test.ts +++ b/tests/exports/unified-result-jsonl.test.ts @@ -3,6 +3,7 @@ import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import { buildUnifiedResultJsonl, queryAllUnifiedResults, + RESULT_DB_EXPORT_HANDLING, RESULT_DB_JSONL_FORMAT, RESULT_DB_JSONL_SCHEMA_VERSION, unifiedResultJsonlFileName @@ -48,8 +49,9 @@ describe('filtered Result DB JSONL export', () => { recordType: 'manifest', format: RESULT_DB_JSONL_FORMAT, schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION, - disclosurePolicyVersion: '2.5.3', - generatedAt: '2026-09-03T12:34:56.789Z', + disclosurePolicyVersion: '3.4.0', + handling: RESULT_DB_EXPORT_HANDLING, + generatedOn: '2026-09-03', resultCount: 2, scope: { projectScoped: true, assessmentScopeCount: 0, scanScopeCount: 1, evaluationScoped: false }, filters: { deviation: true, severity: { active: true, selectionCount: 2 } } @@ -60,7 +62,8 @@ describe('filtered Result DB JSONL export', () => { expect(text).not.toMatch(/project-1|assessment-1|scan-1|source-result-1|synthetic/i); expect(text.endsWith('\n')).toBe(true); expect(unifiedResultJsonlFileName('2026-09-03T12:34:56.789Z')) - .toBe('assessor-result-db-filtered-2026-09-03T12-34-56Z.jsonl'); + .toBe('assessor-result-db-filtered-2026-09-03.jsonl'); + expect(text).not.toContain('12:34'); }); it('loads every filtered page with the same scope and rejects a changing result set', async () => { @@ -139,7 +142,29 @@ describe('filtered Result DB JSONL export', () => { expect(text).not.toMatch(/person@|Private Company|Jane Doe|private-company|private-run|private\/company\/path/i); }); - it('exports the same actionable GPO comparison shown in Result DB', () => { + it('never exports client-owned review, priority or risk-acceptance state', () => { + const reviewed = { + ...result('synthetic-a'), sourceTool: 'purple-knight', sourceRuleId: 'SI000055', + reviewStatus: 'open', reviewDisposition: 'accepted', reviewPriority: 'high', + reviewOwner: 'Synthetic Owner', reviewDueDate: '2026-10-01' + } satisfies UnifiedResultProjection; + const text = buildUnifiedResultJsonl({ reviewDisposition: ['accepted'] }, [reviewed], '2026-09-28T10:00:00.000Z'); + const [manifest, line] = text.trimEnd().split('\n').map(item => JSON.parse(item)); + + expect(manifest.handling).toEqual({ classification: 'confidential-security-posture', notice: expect.any(String) }); + expect(manifest.filters).toEqual({}); + expect(line.result.sourceRuleId).toBe('SI000055'); + expect(text).not.toMatch(/review|priority|accepted|Synthetic Owner|2026-10-01/i); + }); + + it('drops fields that the disclosure policy has not classified', () => { + const extended = { ...result('extended'), futureCustomerField: 'Synthetic Customer' } as UnifiedResultProjection; + const text = buildUnifiedResultJsonl({}, [extended], '2026-09-28T10:00:00.000Z'); + + expect(text).not.toMatch(/futureCustomerField|Synthetic Customer/); + }); + + it('exports the GPO setting, Soll value and baseline family without customer values', () => { const policy = { ...result('policy-result'), sourceKind: 'policy-evaluation' as const, sourceTool: 'GPOs', sourceRuleId: 'registry:machine:software\\policies\\synthetic:mode', title: 'Synthetic policy mode', @@ -155,16 +180,14 @@ describe('filtered Result DB JSONL export', () => { const text = buildUnifiedResultJsonl({}, [policy], '2026-09-23T10:00:00.000Z'); expect(text).toContain('Synthetic policy mode'); expect(text).toContain('registry:machine:software'); - expect(text).toContain('policyActualValue'); expect(text).toContain('policyBaselineValue'); - expect(text).toContain('BUILTIN\\\\Administrators'); - expect(text).toContain('[DOMAIN]\\\\Domain Admins'); - expect(text).toContain('[DOMAIN_PRINCIPAL_1]'); - expect(text).toContain('S-1-5-21-[DOMAIN]-512'); + expect(text).toContain('"policyBaselineLabel":"Microsoft Security Baseline - Windows Server"'); + expect(text).not.toContain('policyActualValue'); + expect(text).not.toMatch(/Administrators|Domain Admins|PRINCIPAL|S-1-5-21|2022/); expect(text).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|private-policy-hash/); }); - it('exports numeric GPO values and public CIS guidance without an application-pool SID', () => { + it('exports the GPO Soll value and public CIS guidance without the customer value or a system SID', () => { const systemSid = 'S-1-5-82-123456789-234567890-345678901-456789012-567890123'; const cis = { ...result('cis'), sourceTool: 'cis-cat', sourceRuleId: '18.10.36.1', title: "18.10.36.1 Ensure 'Turn off location' is set to 'Enabled'", @@ -179,9 +202,9 @@ describe('filtered Result DB JSONL export', () => { const exported = buildUnifiedResultJsonl({}, [cis, gpo], '2026-09-25T10:00:00.000Z'); expect(exported).toContain('18.10.36.1'); expect(exported).toContain('Computer Configuration\\\\Administrative Templates'); - expect(exported).toContain('"value":"5"'); expect(exported).toContain('"value":"10"'); - expect(exported).toContain('[SYSTEM_PRINCIPAL_1]'); + expect(exported).not.toContain('"value":"5"'); + expect(exported).not.toContain('PRINCIPAL'); expect(exported).not.toContain(systemSid); }); }); diff --git a/tests/features/dashboard-environment-detail.test.ts b/tests/features/dashboard-environment-detail.test.ts index 7ff97ec..52ffcb1 100644 --- a/tests/features/dashboard-environment-detail.test.ts +++ b/tests/features/dashboard-environment-detail.test.ts @@ -117,7 +117,7 @@ describe('client info environment detail', () => { expect(detail.restricted).toEqual(['Honey Pot', 'Microsoft Entra Connect settings']); }); - it('shows double-size environment tiles with summary facts and keeps complete evidence in Results', () => { + it('keeps all six square overview tiles on one row and complete evidence in Results', () => { expect(component).toContain('className="client-environment-grid" aria-label="Environment overview"'); expect(component).not.toContain('className="client-environment-detail"'); expect(component).not.toContain('environmentDetailRef'); @@ -137,8 +137,15 @@ describe('client info environment detail', () => { expect(component).not.toContain('title="Source metrics" items={environmentDetail.toolContext}'); expect(component).toContain('icon={}'); expect(component).not.toContain('className="client-source-overview"'); - expect(styles).toMatch(/\.client-environment-grid\s*\{[^}]*grid-auto-flow:\s*column;[^}]*grid-auto-columns:\s*min\(var\(--tile-double-compact-width\), 100%\);[^}]*overflow-x:\s*auto;/s); - expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*min-height:\s*var\(--tile-double-compact-width\);[^}]*background:\s*var\(--tile-surface\);/s); + const overviewLayout = styles.match(/\.client-environment-grid\s*\{([^}]*)\}/)?.[1] ?? ''; + expect(overviewLayout).toMatch(/display:\s*grid;\s*grid-template-columns:\s*repeat\(6, minmax\(var\(--tile-environment-min-width\), 1fr\)\);/); + expect(overviewLayout).toMatch(/overflow-x:\s*auto;/); + expect(overviewLayout).not.toMatch(/flex-wrap:\s*wrap|grid-template-rows:/); + expect(component).toContain('const tileItems = (overviewItems ?? items).slice(0, 4);'); + expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*aspect-ratio:\s*1;[^}]*overflow:\s*hidden;/s); + expect(styles).toMatch(/@container environment-tile \(max-width: 17rem\)\s*\{\s*\.client-env-facts > div:nth-child\(n \+ 3\)\s*\{\s*display:\s*none;/s); + expect(styles).not.toMatch(/\.client-environment-grid > section > \.client-env-facts dd\s*\{[^}]*overflow-wrap:\s*normal;/s); + expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*background:\s*var\(--tile-surface\);/s); expect(styles).toMatch(/\.client-environment-sources li > div,[\s\S]*?\.client-environment-sources li > button\s*\{[^}]*background:\s*transparent;/s); expect(styles).toMatch(/\.client-env-group-title > \.client-glyph\s*\{[^}]*width:\s*var\(--space-5\);[^}]*color:\s*var\(--client-info-muted\);/s); expect(styles).toMatch(/\.client-environment-grid \.client-env-group-title,[\s\S]*?\.client-command-center \.client-environment-grid > \.client-os-inventory > header h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s); diff --git a/tests/features/finding-reader.test.ts b/tests/features/finding-reader.test.ts index 97888fc..bbf2202 100644 --- a/tests/features/finding-reader.test.ts +++ b/tests/features/finding-reader.test.ts @@ -524,7 +524,8 @@ describe('selectFindingFields', () => { sheets: [], sections: resultSectionsFor(attachmentRecord, 'maester') })); - expect(reader).not.toContain('Your tenant does not cover all recommended'); + // User decision 2026-09-28: the statement is the problem; the table stays in Results only. + expect(reader).toContain('
    Result

    Your tenant does not cover all recommended file attachment extensions:

    '); expect(reader).not.toContain('

    Test result

    '); expect(reader).not.toContain('Extension name'); expect(reader).toContain('CIS.M365.2.1.11'); diff --git a/tests/features/results-browser-export-disclosure.test.tsx b/tests/features/results-browser-export-disclosure.test.tsx new file mode 100644 index 0000000..56af082 --- /dev/null +++ b/tests/features/results-browser-export-disclosure.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import { deriveUnifiedResultFacets, queryUnifiedResultItems, type UnifiedResultProjection } from '../../src/domain/unified-result'; +import { ResultRow, ResultsBrowser } from '../../src/features/results-browser/ResultsBrowser'; +import { discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure'; +import { ResultDbIdentityGuard } from '../../src/domain/result-db-identity-guard'; +import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter'; + +function reviewedFinding(id: string): UnifiedResultProjection { + return { + resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceKind: 'scanner-finding', + sourceTool: 'purple-knight', sourceRuleId: 'SI000055', projectId: 'synthetic', assessmentId: 'slot', + collectionRunId: 'collection', scanRunId: 'scan', title: `Synthetic delegation finding ${id}`, + severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail', + technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true, + lifecycleState: 'unknown', mappingConfidence: 1, + reviewStatus: 'open', reviewDisposition: 'accepted', reviewPriority: 'critical', + reviewOwner: 'Synthetic Owner', reviewDueDate: '2026-10-01', + importedAt: '2026-09-28T00:00:00Z', createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: [] + }; +} + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe('Result DB export disclosure', () => { + it('shows no review column or review filter and exports JSONL without review state or environment fingerprints', async () => { + const items = [reviewedFinding('a'), { + ...reviewedFinding('b'), sourceTool: 'pingcastle', sourceRuleId: 'A-NotEnoughDC', + title: 'The number of DCs is too small to provide redundancy: 1 DC', + description: 'Last backup 55 days ago; 15 accounts never expire.' + }]; + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets') + .mockImplementation(async query => deriveUnifiedResultFacets(items, query)); + vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults') + .mockImplementation(async query => queryUnifiedResultItems(items, query)); + const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined); + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => root.render()); + expect(container.querySelectorAll('.results-browser-title')).toHaveLength(2); + const headings = [...container.querySelectorAll('.results-browser-head span')].map(item => item.textContent); + expect(headings).not.toContain('Review'); + expect(container.querySelector('[data-label="Review"]')).toBeNull(); + const filterHeadings = [...container.querySelectorAll('section.results-filter-group h3')].map(item => item.textContent); + expect(filterHeadings.some(heading => /review/i.test(heading ?? ''))).toBe(false); + + const exportButton = [...container.querySelectorAll('button')] + .find(button => button.textContent === 'Export JSONL')!; + await act(async () => exportButton.click()); + + expect(download).toHaveBeenCalledTimes(1); + const text = download.mock.calls[0][0].text; + const [manifest, ...results] = text.trimEnd().split('\n').map(line => JSON.parse(line)); + expect(manifest.handling.classification).toBe('confidential-security-posture'); + expect(results).toHaveLength(2); + expect(results.map(line => line.result.sourceRuleId).sort()).toEqual(['A-NotEnoughDC', 'SI000055']); + expect(text).not.toMatch(/reviewStatus|reviewDisposition|reviewPriority|reviewOwner|reviewDueDate|Synthetic Owner|accepted/); + const dc = results.find(line => line.result.sourceRuleId === 'A-NotEnoughDC')!.result; + expect(dc.title).toBe('The number of DCs is too small to provide redundancy: [count] DC'); + expect(dc.description).toBe('Last backup [duration] ago; [count] accounts never expire.'); + expect(manifest).not.toHaveProperty('generatedAt'); + expect(download.mock.calls[0][0].fileName).toMatch(/^assessor-result-db-filtered-\d{4}-\d{2}-\d{2}\.jsonl$/); + } finally { await act(async () => root.unmount()); } + }); + + it('never reveals that a stored row had client review state, neither in the row detail nor in the JSONL export', async () => { + // The backend serves materialized rows that carry their trace, so this is the real export input. + const reviewed = discloseResultDbProjectionWithTrace({ ...reviewedFinding('first'), score: 27 }); + const unreviewed = discloseResultDbProjectionWithTrace({ + ...reviewedFinding('second'), reviewStatus: undefined, reviewDisposition: undefined, + reviewPriority: undefined, reviewOwner: undefined, reviewDueDate: undefined, score: 96 + }); + const items = [reviewed, unreviewed]; + expect(reviewed.disclosureTrace?.withheldFields).toEqual(unreviewed.disclosureTrace?.withheldFields); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets') + .mockImplementation(async query => deriveUnifiedResultFacets(items, query)); + vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults') + .mockImplementation(async query => queryUnifiedResultItems(items, query)); + const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined); + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => root.render( undefined} + workRecordIds={[]} onOpenInWork={() => undefined}/>)); + expect(container.textContent).not.toMatch(/review|Synthetic Owner/i); + + await act(async () => root.render()); + const exportButton = [...container.querySelectorAll('button')] + .find(button => button.textContent === 'Export JSONL')!; + await act(async () => exportButton.click()); + const text = download.mock.calls[0][0].text; + const results = text.trimEnd().split('\n').slice(1).map(line => JSON.parse(line).result); + expect(results).toHaveLength(2); + expect(results.every(result => result.disclosureTrace)).toBe(true); + expect(text).not.toMatch(/review|Synthetic Owner/i); + expect(results.some(result => 'score' in result)).toBe(false); + } finally { await act(async () => root.unmount()); } + }); + + it('shows the stored trace of a row and opens its original in Work without showing it in the Result DB', async () => { + const stored = discloseResultDbProjectionWithTrace({ + ...reviewedFinding('traced'), affectedAsset: 'SYNTHETIC-HOST-9', description: 'Host SRVDC01 answers for 3 hosts.' + }, new ResultDbIdentityGuard(['SRVDC01'])); + const opened = vi.fn(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => root.render( undefined} + workRecordIds={['work-record-1']} onOpenInWork={opened}/>)); + const detail = container.querySelector('.results-disclosure-trace')?.textContent ?? ''; + expect(detail).toContain('Checked content'); + expect(detail).toContain('Redacted in Description: count × 1, identity-guard × 1'); + expect(detail).toContain('Withheld fields:'); + expect(container.textContent).not.toContain('SRVDC01'); + const button = [...container.querySelectorAll('button')].find(item => item.textContent === 'Open original in Work')!; + await act(async () => button.click()); + expect(opened).toHaveBeenCalledWith(['work-record-1']); + + await act(async () => root.render( undefined} workRecordIds={[]} onOpenInWork={opened}/>)); + expect(container.textContent).toContain('The original is not part of the loaded Work scope.'); + } finally { await act(async () => root.unmount()); } + }); +}); diff --git a/tests/features/results-browser-policy-filters.test.tsx b/tests/features/results-browser-policy-filters.test.tsx index d2eb353..8cd7a94 100644 --- a/tests/features/results-browser-policy-filters.test.tsx +++ b/tests/features/results-browser-policy-filters.test.tsx @@ -22,7 +22,7 @@ function result(id: string, classification: NonNullable { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('Result DB policy filter consistency', () => { - it('shows the concrete GPO setting and readable configured and expected values when expanded', async () => { + it('shows the concrete GPO setting, expected value and relation without the customer value when expanded', async () => { const item: UnifiedResultProjection = { ...result('gpo-readable', 'misconfigured'), sourceRuleId: 'registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators', @@ -47,17 +47,16 @@ describe('Result DB policy filter consistency', () => { expect(container.querySelector('[data-label="Source Rule ID"]')?.textContent) .toBe('registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators'); const detail = container.querySelector('.results-browser-row-detail')?.textContent ?? ''; - expect(detail).toContain('Configured customer value'); - expect(detail).toContain('Data: 0'); + // 2026-09-28: the Result DB shows the Soll value and relation only; the customer value stays in Work. + expect(detail).not.toContain('Configured customer value'); + expect(detail).not.toContain('Data: 0'); expect(detail).toContain('Expected baseline value'); expect(detail).toContain('Data: 1'); + expect(detail).toContain('Comparison: different'); expect(detail).toContain('Technical setting ID: registry:machine:software\\policies\\microsoft\\windows nt\\printers\\pointandprint:restrictdriverinstallationtoadministrators'); - expect(detail).toContain('Microsoft Security Baseline Windows Server 2022 v1'); - expect(detail).toContain('BUILTIN\\Administrators'); - expect(detail).toContain('[DOMAIN]\\Domain Admins'); - expect(detail).toContain('[DOMAIN_PRINCIPAL_1]'); - expect(detail).toContain('S-1-5-21-[DOMAIN]-512'); + expect(detail).toContain('Baseline: Microsoft Security Baseline - Windows Server'); expect(detail).toContain('Backup comparison; effective endpoint state not verified'); + expect(detail).not.toMatch(/Administrators|Domain Admins|PRINCIPAL|S-1-5-21|2022/); expect(detail).not.toMatch(/DUMMY|named\.user|123456789|A1B2C3D4|99999999|private-policy-hash|private-baseline-id/); expect(detail).not.toContain('Privacy-safe Group Policy comparison'); } finally { await act(async () => root.unmount()); } diff --git a/tests/features/results-browser-work-record-lookup.test.ts b/tests/features/results-browser-work-record-lookup.test.ts new file mode 100644 index 0000000..45b527c --- /dev/null +++ b/tests/features/results-browser-work-record-lookup.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import type { ImportedDataset } from '../../src/domain/dataset'; +import type { UnifiedResultProjection } from '../../src/domain/unified-result'; +import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup'; + +function dataset(records: Array<{ id: string; findingOccurrenceId?: string }>): ImportedDataset { + return { sheets: [{ analysis: { records } }] } as unknown as ImportedDataset; +} + +describe('Result DB original lookup in Work', () => { + it('finds the Work record of a scanner row by its finding occurrence', () => { + const row = { sourceKind: 'scanner-finding', sourceResultId: 'occurrence-7' } as UnifiedResultProjection; + expect(workRecordIdsForResult(dataset([{ id: 'record-a', findingOccurrenceId: 'occurrence-7' }, + { id: 'record-b', findingOccurrenceId: 'occurrence-8' }]), row)).toEqual(['record-a']); + }); + + it('finds the Work setting of a GPO comparison by scope and technical setting identity', () => { + const key = (scope: string, identity: string) => + `gpo-work-setting:${encodeURIComponent(JSON.stringify(['project', 'aggregation', scope, identity]))}`; + const row = { sourceKind: 'policy-evaluation', policyScope: 'machine', sourceRuleId: 'registry:machine:software\\x:y' } as unknown as UnifiedResultProjection; + expect(workRecordIdsForResult(dataset([{ id: key('machine', 'registry:machine:software\\x:y') }, + { id: key('user', 'registry:machine:software\\x:y') }, { id: 'gpo-work-setting:%7Bbroken' }]), row)) + .toEqual([key('machine', 'registry:machine:software\\x:y')]); + }); + + it('returns nothing without a loaded Work dataset or for environment rows', () => { + expect(workRecordIdsForResult(null, { sourceKind: 'scanner-finding', sourceResultId: 'x' } as UnifiedResultProjection)).toEqual([]); + expect(workRecordIdsForResult(dataset([{ id: 'r', findingOccurrenceId: 'x' }]), + { sourceKind: 'environment-observation', sourceResultId: 'x' } as UnifiedResultProjection)).toEqual([]); + }); +}); diff --git a/tests/features/scanner-first-read.test.tsx b/tests/features/scanner-first-read.test.tsx index cc1acde..f2d1c36 100644 --- a/tests/features/scanner-first-read.test.tsx +++ b/tests/features/scanner-first-read.test.tsx @@ -119,18 +119,54 @@ describe('scanner first read', () => { expect(headings(container)).toEqual(['Description', 'Rationale']); }); - it('Maester: counts only rows the source marks, keeps the test result out of the reader and links https documentation', () => { + it('Maester: the test result lead as the problem, source-marked row counts and the https documentation link', () => { const item = record([['ID', 'MT.9001'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['ResultDetail', 'Synthetic native test result'], ['Description', 'Synthetic description.'], ['HelpUrl', 'https://maester.dev/docs/tests/MT.9001']]); const results = sheet([['Policy', 'Status'], ['Synthetic A', '❌ Disabled'], ['Synthetic B', '✅ Enabled'], ['Synthetic C', 'Report-only']]); const { container, values } = render(item, 'maester', [results]); - expect(values('.finding-first-read')).toEqual({ Results: '1 of 3 rows marked failed · 1 marked passed' }); - expect(container.querySelector('.reader-summary')?.textContent).not.toContain('Synthetic native test result'); + expect(values('.finding-first-read')).toEqual({ Result: 'Synthetic native test result', Results: '1 of 3 rows marked failed · 1 marked passed' }); const link = container.querySelector('.policy-next-step a')!; expect(link.getAttribute('href')).toBe('https://maester.dev/docs/tests/MT.9001'); expect(link.getAttribute('rel')).toBe('noopener noreferrer'); }); + // Regression 2026-09-28 (user decision): the Maester test result statement was missing from the reader; + // only its first prose paragraph is shown, verbatim, while headings and the impacted-resources table stay in Results. + it('Maester: opening a finding shows the first paragraph of the native test result as the problem', () => { + const detail = '## Test result\n\nYou have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.\n\n' + + '#### Impacted resources\n\n| Status | Name | First detected |\n| :--- | :--- | :--- |\n| ❌ Fail | synthetic-a@example.test | 04/17/2025 |'; + const item = record([['ID', 'MT.1024.adminMFAV2'], ['Title', 'Require multifactor authentication for administrative roles.'], + ['Result', 'Failed'], ['ResultDetail', detail], ['Description', 'Synthetic description.\n\n#### Remediation action:\n\n1. Enable MFA.']]); + const { container, values } = render(item, 'maester'); + expect(values('.finding-first-read')).toEqual({ + Result: 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.' + }); + const reader = container.querySelector('.reader-summary')?.textContent ?? ''; + expect(reader).not.toContain('synthetic-a@example.test'); + expect(reader).not.toContain('Impacted resources'); + expect(item.fields.find(field => field.label === 'ResultDetail')?.value).toBe(detail); + }); + + // Regression 2026-09-28: the Maester Excel export keeps the test result in `Result` next to a separate + // `Status` column, with its line breaks flattened; the statement must still become the problem. + it.each([ + ['CIS.M365.1.1.1', ' Your tenant has 1 or more hybrid Global Administrators: | Display Name | Cloud Only | | --- | --- | | Synthetic A | ❌ Fail |', + 'Your tenant has 1 or more hybrid Global Administrators:'], + ['MT.1024.adminMFAV2', 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA. #### Impacted resources | Status | Name |', + 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.'] + ])('Maester export %s: the Result statement next to a Status column is the problem', (id, result, expected) => { + const item = record([['ID', id], ['Title', 'Synthetic finding'], ['Severity', 'High'], ['Status', 'Failed'], + ['Category', 'CIS'], ['Description', 'Synthetic description.'], ['Result', result]]); + const { values } = render(item, 'maester'); + expect(values('.finding-first-read')).toEqual({ Result: expected }); + expect(item.fields.find(field => field.label === 'Result')?.value).toBe(result); + }); + + it('Maester export without a Status column keeps Result as the status, not as a problem statement', () => { + const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['Description', 'Synthetic description.']]); + expect(render(item, 'maester').container.querySelector('.finding-first-read.policy-problem')).toBeNull(); + }); + // Regression 2026-09-26: the mixed Work sheet carries the generic profile and names each record's scan in // `Source: Scan`. Purple Knight runs are named by scope, which did not resolve to the profile, so the // report showed the fields as plain sections instead of Problem and Next step. diff --git a/tests/persistence/unified-result.test.ts b/tests/persistence/unified-result.test.ts index 50f51a2..90379ea 100644 --- a/tests/persistence/unified-result.test.ts +++ b/tests/persistence/unified-result.test.ts @@ -128,6 +128,42 @@ async function commit( } describe('Unified result read model', () => { + it('serves the stored, checked Result DB unchanged until the data changes, with a trace and a client identity guard', async () => { + const repository = new LocalAssessorRepository(); + await repository.saveProject(project); + await repository.saveAssessment(assessment); + expect(await repository.getResultDbStatus()).toMatchObject({ state: 'not-built', rowCount: 0 }); + + const scanRun = run('run-materialized', 1, 'pingcastle', 'PingCastle'); + const first = definition('definition-host', 'pingcastle:a-dc-spooler', 'Anomalies'); + const second = { ...definition('definition-other', 'pingcastle:a-other', 'Anomalies'), + description: 'The spooler service answers on SRVDC01 for 3 hosts.' }; + const hostFinding = { ...occurrence('occurrence-host', scanRun, first.id, 'failed', 'FAILED', 'A-DC-Spooler'), + affectedAsset: { originalValue: 'SRVDC01', normalizedValue: 'SRVDC01', provenance } }; + await commit(repository, scanRun, [first, second], [hostFinding, + occurrence('occurrence-other', scanRun, second.id, 'failed', 'FAILED', 'A-Other')]); + + const firstRead = await repository.queryUnifiedResults({ projectId: project.id }); + const status = await repository.getResultDbStatus(); + expect(status).toMatchObject({ state: 'current', rowCount: 2, disclosurePolicyVersion: expect.any(String), + identityGuardVersion: expect.any(String) }); + const other = firstRead.items.find(item => item.sourceResultId === 'occurrence-other')!; + // SRVDC01 is only the affected asset of another finding; the client guard still removes it here. + expect(other.description).toBe('The spooler service answers on [redacted] for [count] hosts.'); + expect(other.disclosureTrace?.redactions).toEqual(expect.arrayContaining([ + { field: 'description', rule: 'identity-guard', count: 1 }, { field: 'description', rule: 'count', count: 1 }])); + expect(JSON.stringify(firstRead)).not.toContain('SRVDC01'); + + const secondRead = await repository.queryUnifiedResults({ projectId: project.id }); + expect(secondRead).toEqual(firstRead); + expect((await repository.getResultDbStatus()).materializedAt).toBe(status.materializedAt); + + await repository.saveProject({ ...project, name: 'Synthetic project renamed', updatedAt: timestamp }); + expect(await repository.getResultDbStatus()).toMatchObject({ state: 'outdated' }); + await repository.queryUnifiedResults({ projectId: project.id }); + expect(await repository.getResultDbStatus()).toMatchObject({ state: 'current' }); + }); + it('shows the native public PingCastle rule ID and report title through the normal Result DB query', async () => { const repository = new LocalAssessorRepository(); await repository.saveProject(project); @@ -225,11 +261,16 @@ describe('Unified result read model', () => { isIoe: true, exposureState: 'detected', lifecycleState: 'unknown', - reviewStatus: 'resolved', - reviewDisposition: 'accepted', categories: [{ namespace: 'source-tool', categoryLabel: 'PingCastle' }] }); - expect(first).not.toHaveProperty('reviewOwner'); + // Client-owned review state is internal analysis only; the Result DB withholds it. + for (const key of ['reviewOwner', 'reviewStatus', 'reviewDisposition', 'reviewPriority']) { + expect(first).not.toHaveProperty(key); + } + const internal = await repository.queryUnifiedResults({ audience: 'internal-analysis', projectId: project.id }); + expect(internal.items.find(item => item.sourceResultId === 'occurrence-1')).toMatchObject({ + reviewStatus: 'resolved', reviewDisposition: 'accepted', reviewPriority: 'high' + }); const executionError = await repository.queryUnifiedResults({ outcome: ['execution_error'], @@ -333,6 +374,13 @@ describe('Unified result read model', () => { normalizedValue: 'private.synthetic.example', valueType: 'string', state: 'observed', sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/1' }, mappingVersion: '1.2.0+context-1.0.0', attributes: { customerMarker: 'private-synthetic' } + }, { + version: 1, id: 'environment-recycle-bin', scanRunId: scanRun.id, importFileId: importFile.id, tool: scanRun.tool, + subject: { type: 'domain-information', identity: 'private.synthetic.example' }, + property: 'domain.recycle_bin_enabled', label: 'Recycle Bin', originalValue: 'Enabled', + normalizedValue: true, valueType: 'boolean', state: 'observed', + sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/2' }, + mappingVersion: '1.2.0+context-1.0.0', attributes: { customerMarker: 'private-synthetic' } }] }); await session.commit(); @@ -340,34 +388,36 @@ describe('Unified result read model', () => { const results = await repository.queryUnifiedResults({ projectId: project.id, sourceKind: ['environment-observation'], - environmentProperty: ['domain.functional_level'], + environmentProperty: ['domain.recycle_bin_enabled'], environmentSubjectType: ['active-directory-environment'], - environmentState: ['observed'], - search: '2016' + environmentState: ['observed'] }); expect(results).toMatchObject({ total: 1, items: [expect.objectContaining({ - resultProjectionId: 'environment-observation:environment-dfl', + resultProjectionId: 'environment-observation:environment-recycle-bin', sourceKind: 'environment-observation', sourceTool: 'pingcastle', sourceProfileVersion: '1.5.0', mappingRuleVersion: '1.2.0+context-1.0.0', processingStatus: 'current', - environmentProperty: 'domain.functional_level', + environmentProperty: 'domain.recycle_bin_enabled', environmentSubjectType: 'active-directory-environment', - environmentNormalizedValue: 2016, - environmentValueType: 'number', + environmentNormalizedValue: true, + environmentValueType: 'boolean', environmentState: 'observed', - supplementalDisclosurePolicyVersion: '1.0.0', - supplementalDisclosureClass: 'technical-metadata', + supplementalDisclosurePolicyVersion: '2.0.0', + supplementalDisclosureClass: 'configuration-state', resultType: 'informational', isIoe: false, isDeviation: false })] }); + await expect(repository.getUnifiedResultById('environment-observation:environment-recycle-bin')) + .resolves.toMatchObject({ sourceResultId: 'environment-recycle-bin', environmentNormalizedValue: true }); + // Functional levels, counts, scores and versions fingerprint the environment and stay internal. await expect(repository.getUnifiedResultById('environment-observation:environment-dfl')) - .resolves.toMatchObject({ sourceResultId: 'environment-dfl', environmentNormalizedValue: 2016 }); + .resolves.toBeNull(); await expect(repository.getUnifiedResultById('environment-observation:environment-domain-name')) .resolves.toBeNull(); expect(JSON.stringify(results)).not.toContain('synthetic.example'); @@ -383,7 +433,7 @@ describe('Unified result read model', () => { expect(facets.facets.find(facet => facet.id === 'sourceKind')?.values) .toContainEqual(expect.objectContaining({ value: 'environment-observation', count: 1 })); expect(facets.facets.find(facet => facet.id === 'environmentProperty')?.values) - .toContainEqual(expect.objectContaining({ value: 'domain.functional_level', count: 1 })); + .toEqual([expect.objectContaining({ value: 'domain.recycle_bin_enabled', count: 1 })]); expect(facets.facets.find(facet => facet.id === 'environmentState')?.values) .toContainEqual(expect.objectContaining({ value: 'observed', count: 1 })); expect(facets.facets.find(facet => facet.id === 'environmentSubjectType')?.values) diff --git a/tests/postgres/postgres-repository.integration.test.ts b/tests/postgres/postgres-repository.integration.test.ts index fc48b70..f7d6874 100644 --- a/tests/postgres/postgres-repository.integration.test.ts +++ b/tests/postgres/postgres-repository.integration.test.ts @@ -123,7 +123,12 @@ describePostgres('PostgresAssessorRepository integration', () => { technicalExecutionState: 'completed' }] }); - await expect(repository.getUnifiedResultById('scanner-finding:pg-occurrence-1')).resolves.toMatchObject({ + // Client-owned review state stays in internal analysis; the Result DB withholds it. + const disclosed = await repository.getUnifiedResultById('scanner-finding:pg-occurrence-1'); + expect(disclosed).not.toHaveProperty('reviewStatus'); + expect(disclosed).not.toHaveProperty('reviewPriority'); + const internal = await repository.queryUnifiedResults({ audience: 'internal-analysis', projectId: project.id }); + expect(internal.items.find(item => item.sourceResultId === 'pg-occurrence-1')).toMatchObject({ reviewStatus: 'open', reviewPriority: 'high' }); diff --git a/tests/postgres/result-db-materialization-schema.test.ts b/tests/postgres/result-db-materialization-schema.test.ts new file mode 100644 index 0000000..fd05f6d --- /dev/null +++ b/tests/postgres/result-db-materialization-schema.test.ts @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { databaseMigrations } from '../../server/postgres/migration-catalog'; + +const root = resolve(import.meta.dirname, '..', '..', 'server', 'postgres'); + +describe('PostgreSQL materialized Result DB migration', () => { + it('is registered as an additive migration after reference recognition', () => { + expect(databaseMigrations.find(migration => migration.version === '024')).toMatchObject({ + version: '024', name: 'result-db-materialization' + }); + }); + + it('stores derived rows per client with cascade deletion and one state record', async () => { + const sql = await readFile(resolve(root, 'migrations', '024_result_db_materialization.sql'), 'utf8'); + expect(sql).toContain('CREATE TABLE assessor_result_db_rows'); + expect(sql).toContain('REFERENCES assessor_projects(id) ON DELETE CASCADE'); + expect(sql).toContain('CREATE TABLE assessor_result_db_state'); + expect(sql).not.toMatch(/\bDROP\b|\bALTER TABLE assessor_(?!result_db)/u); + }); + + it('serves every Result DB read from the stored rows and rebuilds after committed changes', async () => { + const repository = await readFile(resolve(root, 'postgres-assessor-repository.ts'), 'utf8'); + expect(repository).toContain("if (query.audience !== 'internal-analysis') return this.storedResultDbRows(query.projectId);"); + expect(repository).toContain("'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1'"); + expect(repository).toContain('if (changed(result)) this.scheduleResultDbRebuild();'); + expect(repository).toContain('pg_advisory_lock($1)'); + expect(repository).not.toContain('discloseResultDbProjection('); + }); +}); diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts index dd4dbce..b4c1667 100644 --- a/tests/server/assessor-http-server.test.ts +++ b/tests/server/assessor-http-server.test.ts @@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => { const health = await fetch(`${baseUrl}/api/v1/health`); expect(await health.json()).toEqual({ - status: 'ok', application: 'ASSESSOR', productVersion: '2.8.7', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' + status: 'ok', application: 'ASSESSOR', productVersion: '2.9.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' }); expect(health.headers.get('access-control-allow-origin')).toBeNull(); }); diff --git a/tests/server/gpo-policy-evaluation-service.test.ts b/tests/server/gpo-policy-evaluation-service.test.ts index 6e0906e..cc48e22 100644 --- a/tests/server/gpo-policy-evaluation-service.test.ts +++ b/tests/server/gpo-policy-evaluation-service.test.ts @@ -461,7 +461,9 @@ describe('baseline-driven policy evaluation', () => { policyBaselineVariantAssessment: saved.results[0].baselineVariantAssessment }); const html = renderToStaticMarkup(createElement(ResultRow, { item: unified, expanded: true, onToggle() {} })); - expect(html).toContain('Setting reference:'); + // The Result DB lists collapsed baseline expectations with their relation, never customer values. + expect(html).toContain('Value comparison:'); + expect(html).not.toContain('Configured customer value'); expect(html).toContain('No effective device state'); expect(html).toContain('Configured Group Policy value compared with the retained baseline recommendation.'); // The saved per-variant values and verdicts remain available consistently in Work guidance