Compare commits

...

2 Commits

Author SHA1 Message Date
Michael d8f131d6da Anpassung Mitre Catalog Update 2026-09-28 09:03:24 +02:00
Michael add2aceb1d 2.8.6: reference recognition, scanner report first read and CIS section titles
Reference recognition (Phase 13D, 2.8.0-2.8.5):
- ATT&CK 19.2 / D3FEND 1.6.0 catalogs, versioned recognizer and
  append-only reference runs (migration 023), catalog references in the
  reader, Data Hub status and reprocessing, MITRE online update.

Scanner report and Work (2.8.6):
- Resolve scope-named Purple Knight runs to their profile via runNames.
- Maester "Remediation action" section as next step; PowerShell scripts
  as command blocks with display-only line layout.
- Purple Knight likelihood without the appended references section.
- CIS section titles from the run's own report in the CIS Category
  filter, Work headings and badge tooltips.
- Keep all record metadata when GPO results rebuild All Records
  (withRebuiltLayout) with a guard test for future metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:12:33 +02:00
91 changed files with 3210 additions and 159 deletions

View File

@ -178,6 +178,7 @@
## UI Client Info dashboard ## UI Client Info dashboard
- Explicit user correction 2026-09-26: the proposed Work-style dashboard rearrangement was rejected and fully reverted. Preserve the existing tile layout, Overall Score, domain connections and Results popups. Reduce information only by removing duplicate overview facts within the existing tiles: Domain, Forest, both functional levels and DC OS stay in Domain Information; NetBIOS, schema version, domain creation and Entra identity stay in Directory; aggregate counts stay in Data. Remove repeated counts from Directory, Domain Information and Inventory only when they match the same fact already shown elsewhere; retain differing or non-numeric source values. Equal values for distinct facts (for example Domain and Forest) are not duplicates. Directory and Inventory Results retain their complete facts and original tables. This supersedes only earlier requirements to repeat those facts in several overview tiles.
- Explicit user correction 2026-09-20: the former Client Info `Priority Findings` tile is named `Data`, sits to the left of `Domain Information`, and shows the total `Deviations` value plus the separate additional-result-state row only. Do not show duplicate `Critical` or `High` summary values in that tile. The remaining deviation value occupies the full header width and is a keyboard-accessible button that opens Work with the standard `Deviation` filter. - Explicit user correction 2026-09-20: the former Client Info `Priority Findings` tile is named `Data`, sits to the left of `Domain Information`, and shows the total `Deviations` value plus the separate additional-result-state row only. Do not show duplicate `Critical` or `High` summary values in that tile. The remaining deviation value occupies the full header width and is a keyboard-accessible button that opens Work with the standard `Deviation` filter.
- Explicit user correction 2026-09-18 supersedes the previous Client Info visual exception: `.client-command-center` follows the same Ableton-inspired ASSESSOR language as the approved Data Hub, Work and Pathfinder. Client Info may organize its own domain content, but has no exception for palette, surfaces, tiles, typography, controls, selection, branding, spacing or chart geometry. Existing `--client-info-*` roles are legacy implementation details, not authority for a separate visual theme; reuse the shared roles when bringing this view into conformity. - Explicit user correction 2026-09-18 supersedes the previous Client Info visual exception: `.client-command-center` follows the same Ableton-inspired ASSESSOR language as the approved Data Hub, Work and Pathfinder. Client Info may organize its own domain content, but has no exception for palette, surfaces, tiles, typography, controls, selection, branding, spacing or chart geometry. Existing `--client-info-*` roles are legacy implementation details, not authority for a separate visual theme; reuse the shared roles when bringing this view into conformity.
- Treat Client Info as one connected executive security-posture canvas, not as a collection of unrelated report cards or a spreadsheet. Its stable reading order is overall posture, security domains, Data and Domain Information, then source evidence. - Treat Client Info as one connected executive security-posture canvas, not as a collection of unrelated report cards or a spreadsheet. Its stable reading order is overall posture, security domains, Data and Domain Information, then source evidence.
@ -226,6 +227,13 @@
## Navigation and rendering performance ## Navigation and rendering performance
- Explicit user correction 2026-09-26: `Catalog references` starts collapsed for a current client, with a small green dot before its heading and before `Up to date` / `Update successful`. Pending work or a failed check/update opens it automatically and uses an orange heading dot; checking, processing and empty clients never get a green success signal. ATT&CK/D3FEND versions remain visible while collapsed. Keep the success message open after updating and retain manual disclosure and keyboard operation. Center the Timeslots heading and add action on the same horizontal line with equal top/bottom spacing.
- Explicit user extension 2026-09-26: Timeslot entries name Purple Knight scopes explicitly as `Purple Knight for Active Directory`, `Purple Knight for Entra ID`, or the existing combined name. Use canonical scope names first, otherwise already retained file metadata or run/file-local environment observations. Never guess from file names, finding titles, other scans or another scanner. This is a display projection only; stored scanner identity, Result DB disclosure and scoring remain unchanged. A historical scan without retained scope facts keeps its source name until reimport supplies them.
- Explicit user correction 2026-09-26: `Catalog references` omits its generic explanation paragraph and states the next action plainly: `Please update` for pending work, `Up to date` with `No action needed.` for a current client, and `Update successful` only after successful execution and a fresh status check with no remaining work. Keep ATT&CK and D3FEND catalog versions visible. The action is now named `Update references`; this supersedes the earlier `Recognize references` caption while retaining its left-side placement and red-when-needed/yellow-otherwise color rule. Empty, checking and failure states must not claim success.
- Explicit user correction 2026-09-26: `Catalog references` belongs in the Data Hub client navigation on the left, directly above `Timeslots`, including while client master data is being edited. The `Recognize references` button is red when recognition is pending or retry is needed, otherwise it uses the standard yellow action treatment. This is an explicit color exception for this reference action; other action and selection colors remain unchanged. Pending references also participate in the client card's processing status (yellow point and highlighted Manage); an actual recognition failure uses the existing red error state. Successful recognition refreshes the card without hiding unrelated import or GPO issues.
- Explicit user correction 2026-09-25: begin preloading Data Hub's six result summaries while the client overview metadata is loading, before a client is selected. Reuse completed and in-flight summaries across selections and Data Hub revisits. Keep speculative requests and retained values bounded, prioritize a selected scope, and key reuse by repository revision plus exact project/assessment/scan scope. Refresh on import/data/GPO changes; stale responses must never overwrite another selection. Load summaries only, not complete source models into the browser. - Explicit user correction 2026-09-25: begin preloading Data Hub's six result summaries while the client overview metadata is loading, before a client is selected. Reuse completed and in-flight summaries across selections and Data Hub revisits. Keep speculative requests and retained values bounded, prioritize a selected scope, and key reuse by repository revision plus exact project/assessment/scan scope. Refresh on import/data/GPO changes; stale responses must never overwrite another selection. Load summaries only, not complete source models into the browser.
- Treat area switches as a performance-sensitive path. Measure the first visit and repeated visits separately after the workspace has loaded; distinguish main-thread rendering from asynchronous API readiness. Use the local procedure in `docs/architecture/navigation-performance.md`. - Treat area switches as a performance-sensitive path. Measure the first visit and repeated visits separately after the workspace has loaded; distinguish main-thread rendering from asynchronous API readiness. Use the local procedure in `docs/architecture/navigation-performance.md`.
- Share expensive filtering, sorting and facet counts between their consumers. Component-local `useMemo` does not survive an area unmount. Use bounded, dataset/sheet-owned caches with immutable input identities and all semantic dependencies (profile, dataset, reviews, filters, search, sort and timeslot/path scope); never cache solely by a reusable entity ID. Verify invalidation as well as reuse. - Share expensive filtering, sorting and facet counts between their consumers. Component-local `useMemo` does not survive an area unmount. Use bounded, dataset/sheet-owned caches with immutable input identities and all semantic dependencies (profile, dataset, reviews, filters, search, sort and timeslot/path scope); never cache solely by a reusable entity ID. Verify invalidation as well as reuse.

View File

@ -1,7 +1,19 @@
# ASSESSOR – Umsetzungsplan # ASSESSOR – Umsetzungsplan
Referenzerkennung 2026-09-26 (Phase 13D, erster Umfang umgesetzt und verifiziert, Version 2.8.0): ATT&CK/D3FEND werden gemeinsam aus gespeicherten Originalfeldern erkannt und mit Quellen-/Katalogherkunft in eigenen Laeufen gespeichert. Normale Batchimports leiten automatisch ab; bestehende Scans werden ueber `Recognize references` im Clientbereich explizit nachverarbeitet. Pathfinder und Reader nutzen die gespeicherten Referenzen. PostgreSQL-Migration 023 mit vorherigem verifiziertem Backup; keine Client-Datenbanktrennung, Finding-Identitaets-, Scoring- oder Review-Aenderung. Siehe Phase 13D und `docs/REFERENCE_RECOGNITION.md`.
Status: lebendes Projektdokument Status: lebendes Projektdokument
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.
2026-09-26 - Phase 7 / Finding-Reader-Abstaende (2.8.4, umgesetzt und verifiziert): Die gemeinsame Abschnittsregel umfasst jetzt div und section, damit GPO-Quellen und Baseline-Vergleich denselben Tokenabstand verwenden: space-4 (16 px) vor/nach der Trennlinie, space-2 (8 px) zwischen Feldueberschrift und Text. Redundante Abschlusslinien am letzten Quellblock und letzten Reader-Feld entfernt; Problem-/Next-step-Sonderabstaende bleiben erhalten. Synthetischer permanenter Klicktest prueft den gerenderten GPO-Reader vor, nach und nach erneutem Schliessen des Baseline-Disclosures. 44 gezielte Reader-/UI-/API-Versionsvertragstests, beide Produktionsbuilds, Design-/Local-only- und Diffpruefung bestanden. Synthetische Light-/Dark-Sichtpruefung bestaetigt jeweils rund 16 px Abschnittsabstand. Patchversion fuer die gemeinsame Darstellungskorrektur; keine Daten-, Import-, Bewertungs- oder Persistenzaenderung. Risiko: Die gemeinsame Regel wirkt auch auf weitere native Reader-Abschnitte; fokussierte Reader-Regressionen decken die bestehenden Faelle ab. Phasenstatus unveraendert.
MITRE-Onlineaktualisierung und Originaldownload 2026-09-26 (Phase 7, Nutzererweiterung, umgesetzt und verifiziert): Data Hub bietet `Update from MITRE`, `Download from MITRE` und den weiterhin vorhandenen manuellen `Import JSON file` als Fallback. Update holt das aktuelle offizielle Enterprise-STIX-Bundle serverseitig, prueft Format und Versionsnummer und aktiviert nur eine neuere Version als gemeinsam gespeicherten kompakten Katalog. Die Datei liegt atomar im privaten Artefaktverzeichnis; kein PostgreSQL-Schema und keine Assessment-Daten werden geaendert. Download liefert die unveraenderte originale MITRE-Datei mit Versionsnamen und aktiviert sie nicht. Der App-Start laedt den gemeinsamen Katalog auch bei direktem Pathfinder-Einstieg; ein aktiver Graph aktualisiert sich im aktuellen Browser sofort. Ein lokaler manueller Override bleibt bewusst browserlokal und wird durch einen erfolgreichen Update-Klick im selben Browser entfernt; andere Browser behalten ihren eigenen Override. Entscheidung: rueckwaertskompatible neue Faehigkeit, daher Minorversion 2.7.0. Risiken/Grenzen: Onlineaktionen benoetigen Erreichbarkeit des offiziellen MITRE-GitHub-Repositories; die bereits gebuendelte v19.2 und der manuelle Import bleiben offline verfuegbar. Ein fremder Browser mit lokalem Override sieht die gemeinsame neue Version erst nach dessen Entfernung. Synthetische Service-/HTTP-/UI-Regressionen, 16 fokussierte MITRE-/Versions-/Data-Hub-Tests, Local-only- und Designpruefung sowie beide Produktionsbuilds bestanden. Ein Live-Updateabruf bestaetigte v19.2 als aktuellen Stand (`updated=false`); der Live-Download antwortete mit HTTP 200, Originaldateiname `enterprise-attack-v19.2.json` und 53.835.637 Bytes. App/Backend laufen mit 2.7.0 und dem kanonischen PostgreSQL; Health `ok`, Repository-Revision 496. Die Gesamtsuite meldete vor Korrektur eines neuen Test-API-Mocks 1.550 bestandene, 13 uebersprungene und sieben fehlgeschlagene Tests; der Mock-Test wurde anschliessend fokussiert erfolgreich erneut geprueft. Sechs vorbestehende fachfremde Fehler in GPO-/Security-Domain-/Result-DB-/Dashboard-Tests bleiben. Der dokumentierte Phasenstatus bleibt unveraendert.
MITRE-ATT&CK-Katalog 2026-09-26 (Phase 7, umgesetzt und verifiziert): Der versionierte Standardkatalog wurde aus MITREs offiziellem Enterprise-STIX-2.1-Bundle v19.2 reproduzierbar erzeugt (15 Taktiken, 697 aktive Techniken, 1.057 aktive Entitaeten). Der Data-Hub-Upload ist wieder sichtbar und akzeptiert offizielles Enterprise-STIX sowie das bisherige kompakte ASSESSOR-JSON; Reset und aktive Versionsanzeige bleiben erhalten. Der geoeffnete Pathfinder uebernimmt Upload und Reset ohne Remount. Die bisherige v18.1-Datei wurde als nicht mehr genutzter Standard entfernt. Entscheidung: rueckwaertskompatible Patchversion 2.6.47, keine Scanner-, API-, Schema-, Datenbank- oder Bestandsdatenmutation. Risiken/Grenzen: Individuelle Matrizen bleiben browserlokale Overrides; ein vorhandener Custom-Stand kann daher weiter aelter als der neue Standard sein, wird aber als `Custom` kenntlich. Im schmalen Codex-Browser war die Karte per Accessibility sichtbar; ein vollbreiter visueller Dark-/Light-Vergleich wurde dort nicht abgenommen. 21 fokussierte MITRE- und UI-/API-Versionsvertragstests, Data-Hub-Layouttest, Design-/Local-only-Pruefung und beide Produktionsbuilds bestanden; laufendes Backend meldet 2.6.47, `ok` und `postgresql`, App HTTP 200. Gesamtsuite: 1.548 bestanden, 13 uebersprungen, sechs fachfremde bestehende Fehler in GPO-/Security-Domain-/Result-DB-/Dashboard-Tests. Der dokumentierte Status von Phase 7 (`abgeschlossen`) bleibt unveraendert; dieser Nachtrag ist separat verifiziert.
Pathfinder-Details-Wertespalte 2026-09-25 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die Faktenzeilen im rechten Detailsbereich nutzten eine automatische Wertespalte, die zusaetzlich auf 66 Prozent ihrer eigenen Breite begrenzt war und Woerter sowie Zahlen mit `overflow-wrap: anywhere` in einzelne Silben beziehungsweise Ziffern zerlegte. Die Werte erhalten jetzt eine feste flexible Spalte mit mehr Platz; mehrteilige Werte brechen nur an Wortgrenzen um, lange ungetrennte technische Kennungen bleiben in ihrem eigenen Bereich horizontal lesbar. Schriftrolle, Quellwerte und semantische Statusfarben bleiben unveraendert. Ein synthetischer Finding-Klicktest prueft die tatsaechlich angewandte Spalten- und Umbruchregel; 14 fokussierte Graph- und UI-/API-Versionsvertragstests, Design-/Local-only-Pruefung und beide Produktionsbuilds bestanden. Backend 2.6.46 am bestehenden kanonischen PostgreSQL gestartet; Health `ok`, App HTTP 200, Repository-Revision 496. Entscheidung: rueckwaertskompatible Patchversion 2.6.46 ohne Daten-, Import-, API-, Schema- oder Persistenzaenderung. Phase 7 bleibt offen. Pathfinder-Details-Wertespalte 2026-09-25 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Die Faktenzeilen im rechten Detailsbereich nutzten eine automatische Wertespalte, die zusaetzlich auf 66 Prozent ihrer eigenen Breite begrenzt war und Woerter sowie Zahlen mit `overflow-wrap: anywhere` in einzelne Silben beziehungsweise Ziffern zerlegte. Die Werte erhalten jetzt eine feste flexible Spalte mit mehr Platz; mehrteilige Werte brechen nur an Wortgrenzen um, lange ungetrennte technische Kennungen bleiben in ihrem eigenen Bereich horizontal lesbar. Schriftrolle, Quellwerte und semantische Statusfarben bleiben unveraendert. Ein synthetischer Finding-Klicktest prueft die tatsaechlich angewandte Spalten- und Umbruchregel; 14 fokussierte Graph- und UI-/API-Versionsvertragstests, Design-/Local-only-Pruefung und beide Produktionsbuilds bestanden. Backend 2.6.46 am bestehenden kanonischen PostgreSQL gestartet; Health `ok`, App HTTP 200, Repository-Revision 496. Entscheidung: rueckwaertskompatible Patchversion 2.6.46 ohne Daten-, Import-, API-, Schema- oder Persistenzaenderung. Phase 7 bleibt offen.
DB-Structure-Einfuehrung entfernt 2026-09-25 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Der redundante `Database dictionary`-/`Relational schema`-Textblock oberhalb der Kennzahlen und der erklaerende Satz unter `Data flow` sind entfernt. Schema-Status, `Refresh schema`, Kennzahlen, Ablaufueberschrift, Tabellenbuttons, Popups und Schichtenverzeichnis bleiben erreichbar. Unbenutzte CSS-Regeln fuer beide Texte sind entfallen; die Aktionszeile bleibt am rechten Rand. Der synthetische Klicktest bestaetigt das Fehlen beider Texte bei weiter bedienbarem Refresh-Button und Ablauf; die vorhandenen Popup- und Dictionary-Klickpfade bestehen weiter. 17 fokussierte UI-/API- und Versionsvertragstests, Design-/Local-only-Pruefung sowie beide Produktionsbuilds bestanden. Backend 2.6.45 am bestehenden kanonischen PostgreSQL gestartet; Health `ok`, App HTTP 200, Repository-Revision 496. Entscheidung: rueckwaertskompatible Patchversion 2.6.45 ohne Daten-, Import-, API-, Schema- oder Persistenzaenderung. Phase 7 bleibt offen. DB-Structure-Einfuehrung entfernt 2026-09-25 (Phase 7, ausdrueckliche Nutzerkorrektur, umgesetzt und verifiziert): Der redundante `Database dictionary`-/`Relational schema`-Textblock oberhalb der Kennzahlen und der erklaerende Satz unter `Data flow` sind entfernt. Schema-Status, `Refresh schema`, Kennzahlen, Ablaufueberschrift, Tabellenbuttons, Popups und Schichtenverzeichnis bleiben erreichbar. Unbenutzte CSS-Regeln fuer beide Texte sind entfallen; die Aktionszeile bleibt am rechten Rand. Der synthetische Klicktest bestaetigt das Fehlen beider Texte bei weiter bedienbarem Refresh-Button und Ablauf; die vorhandenen Popup- und Dictionary-Klickpfade bestehen weiter. 17 fokussierte UI-/API- und Versionsvertragstests, Design-/Local-only-Pruefung sowie beide Produktionsbuilds bestanden. Backend 2.6.45 am bestehenden kanonischen PostgreSQL gestartet; Health `ok`, App HTTP 200, Repository-Revision 496. Entscheidung: rueckwaertskompatible Patchversion 2.6.45 ohne Daten-, Import-, API-, Schema- oder Persistenzaenderung. Phase 7 bleibt offen.
@ -554,6 +566,8 @@ optionaler geschützter Originaldateispeicher
Die heutige Tauri-Anwendung bleibt zunächst eine lokale Laufzeitumgebung. Das React-Frontend und die Fachlogik sollen so getrennt werden, dass dieselbe Oberfläche später über ein Backend im normalen Browser betrieben werden kann. Die heutige Tauri-Anwendung bleibt zunächst eine lokale Laufzeitumgebung. Das React-Frontend und die Fachlogik sollen so getrennt werden, dass dieselbe Oberfläche später über ein Backend im normalen Browser betrieben werden kann.
**Zielbild fuer autonome Client-Datenbanken (Nutzerentscheidung 2026-09-26; geplant, nicht umgesetzt):** Im spaeteren Mehrclientbetrieb erhaelt jeder Client eine eigene fachliche PostgreSQL-Datenbank. Das Backend loest den autorisierten Client auf und verwendet ausschliesslich dessen Datenbank und privaten Artefaktspeicher fuer Stammdaten, Timeslots, Scan- und GPO-Quellen, Analysen, Bewertungen, Reviews und Historie. Die Browsergrenze bleibt die Backend-API. Ein zentraler, versionierter Referenzkatalog verwaltet unveraenderliche offizielle MITRE-ATT&CK-Staende und Microsoft Security Baselines; er enthaelt keine Client-Ergebnisse. Die clientgebundene Auswahl und gegebenenfalls clientindividuelle Baselinepakete gehoeren zum Client. Jede Auswertung dokumentiert die exakt verwendeten Referenzversionen und ihren Hash; ein zentrales Update veraendert weder die Auswahl noch historische Ergebnisse stillschweigend. Fuer eine wirklich autarke Sicherung/Wiederherstellung enthaelt das Client-Backup auch die exakten verwendeten Referenzbytes oder unveraenderliche, mitgesicherte Artefakte und alle clientgebundenen Originale. Ein Restore muss ohne Zugriff auf den zentralen Katalog lesbare Historie und reproduzierbare Auswertungsgrundlagen liefern. Allgemeine Update-/Downloadaktionen bleiben in der Referenzverwaltung; der Client-Bereich zeigt und verwaltet seine verwendeten Versionen. Das ist eine Zielentscheidung fuer die spaetere Mandanten-/Betriebsarchitektur (Phase 14 mit Vorarbeiten an Repository-, Referenz- und Artefaktgrenzen aus Phasen 9-11/13B), keine Aussage ueber den aktuellen lokalen Speicher. Die kanonische lokale PostgreSQL-Datenbank und alle bestehenden Daten bleiben bis zu einer gesondert geplanten, gesicherten Migration unveraendert.
## 5. Statusdefinitionen ## 5. Statusdefinitionen
- `offen`: noch nicht begonnen - `offen`: noch nicht begonnen
@ -583,6 +597,7 @@ Die heutige Tauri-Anwendung bleibt zunächst eine lokale Laufzeitumgebung. Das
| 13A | Ingestion 2.0: Formatadapter, Source Extractors und Mappingprofile konsolidieren | abgeschlossen | | 13A | Ingestion 2.0: Formatadapter, Source Extractors und Mappingprofile konsolidieren | abgeschlossen |
| 13B | Deterministische GPO-Backup- und Microsoft-Baseline-Analyse | in Arbeit | | 13B | Deterministische GPO-Backup- und Microsoft-Baseline-Analyse | in Arbeit |
| 13C | Scanner Context und Environment Inventory | in Arbeit | | 13C | Scanner Context und Environment Inventory | in Arbeit |
| 13D | Referenzerkennung und katalogbasierte Einordnung (ATT&CK/D3FEND erster Umfang) | in Arbeit |
| 14 | Mehrbenutzer- und Firmenbetrieb | offen | | 14 | Mehrbenutzer- und Firmenbetrieb | offen |
## 7. Phasen im Detail ## 7. Phasen im Detail
@ -1933,8 +1948,44 @@ Abnahmekriterien:
- Betrieb, Wiederherstellung und Update sind dokumentiert und getestet. - Betrieb, Wiederherstellung und Update sind dokumentiert und getestet.
- Es findet keine Übertragung an externe Dienste statt, sofern dies nicht später ausdrücklich beschlossen wird. - Es findet keine Übertragung an externe Dienste statt, sofern dies nicht später ausdrücklich beschlossen wird.
### Phase 13D - Referenzerkennung und katalogbasierte Einordnung
2026-09-26 - Phase 13D / gemischte Work-Projektion (2.8.5, umgesetzt und verifiziert): Beim Anhaengen von GPO-Ergebnissen an All Records wird referenceRecognition jetzt vom jeweiligen Scannerbefund uebernommen. Zuvor blieben die Referenzlaeufe gespeichert, gingen aber beim Neuaufbau der Reader-Projektion verloren. Die Uebernahme bewahrt Referenzen, Quellenherkunft, Erkennerversion, Lauf und Katalogversion/-hash einschliesslich bewusst leerer Erkennung; GPOs und nicht indexierte Befunde erhalten keine fremden Referenzen. Synthetische Regression oeffnet Referenzdetails im echten FindingReader mit und ohne GPOs. 30 fokussierte Referenz-/GPO-/CIS-Tests und 9 UI-/API-Versionsvertragstests, beide Produktionsbuilds sowie Design-/Local-only- und Diffpruefung bestanden. Patchversion fuer rueckwaertskompatible Projektionskorrektur; keine Datenmutation und kein Reimport bzw. neuer Erkennungslauf erforderlich. Bereits geladene Workspaces muessen neu geladen werden. Phase 13D bleibt in Arbeit.
Status: in Arbeit (erster Umfang am 2026-09-26 umgesetzt und verifiziert; weitere Frameworks und Folgeetappen offen).
Ziel: gemeinsame, offline faehige Referenzerkennung aus behaltenen Originalfeldern mit unveraenderlicher Katalog- und Quellenherkunft. Erster Umfang: Enterprise ATT&CK und D3FEND; eigenstaendige Ableitung statt Finding-Identitaet oder Scoring-Eingriff. Einzelheiten und Grenzen: `docs/REFERENCE_RECOGNITION.md`.
Abnahme erster Umfang:
- [x] Frameworkweite Erkenner fuer IDs und eindeutige exakte Titel; declared/mentioned, unknown/deprecated/revoked getrennt.
- [x] Quelle mit Feld, Position, UTF-16-Offsets, Originaltext, Datei-/Input-/Kataloghash und Erkennerversion behalten.
- [x] Normale Batchimports speichern Ableitung und Kataloge atomar; bestehende Scannerlaeufe koennen pro Client explizit nachverarbeitet werden.
- [x] Append-only Referenzlaeufe und gemeinsame unveraenderliche Kataloge in PostgreSQL/Repository und Projektexport; synthetisch gegen kanonische PostgreSQL verifiziert.
- [x] Pathfinder liest gespeicherte aktive Referenzen; Reader zeigt auch historische/unbekannte Treffer und Provenienz. Keine neue automatische Finding-Korrelation.
- [x] Client-/Server-Produktionsbuilds und Produktversionsvertrag bestanden; beide Themes mit ausschliesslich synthetischen Komponenten bei normaler und 480px Readerbreite geprueft.
Entscheidungen 2026-09-26: Erkennerversion 1.0.0; gebuendelte Kataloge ATT&CK 19.2 und D3FEND 1.6.0. ATT&CK-Onlineupdate speichert den vollstaendigen Erkennungskatalog mit der Matrix, aendert aber keine frueheren Referenzlaeufe. D3FEND-Laufzeitupdate, weitere Frameworks, technische Identitaetsaufloesung und Korrelationsansicht gehoeren zu spaeteren Etappen. Eine Datenbank pro Client bleibt wie beauftragt ausschliesslich im Zielplan.
Risiken/Grenzen: Nur behaltene Felder koennen ohne Reimport erkannt werden; mehrdeutige Namen werden nicht geraten. Der historische Text `Account Monitoring` ist kein exakter Eintrag des gebuendelten D3FEND-Technikkatalogs und erzeugt deshalb keine freie Graphrelation mehr; der Originaltext bleibt sichtbar. Die manuelle Browsermatrix ist ein Matrixfallback und waehlt keine serverseitige Erkennungsversion. Sehr grosse Client-Nachverarbeitungen koennen spaeter einen persistierten Hintergrundjob benoetigen. Niedrigstufige Staging-API-Imports erhalten Referenzen ueber die explizite Nachverarbeitung. Details im Funktionsdokument.
Migration: 023 (021/022 in der kanonischen DB durch Timeslot-/Obligation-Arbeit belegt, unberuehrt). Vorheriger lokaler PostgreSQL-Backup verifiziert (Custom-Dump, TOC lesbar, SHA-256 dokumentiert in `.assessor-data/backups`; keine Kundendaten ausgegeben). Eigene synthetische Testdaten exakt geloescht.
Versionsentscheidung: neue rueckwaertskompatible Faehigkeit, Minor-Version 2.8.0, Produktversionsvertrag und beide Builds verifiziert; 2.7.0 bleibt als historischer MITRE-Update-Meilenstein dokumentiert. Keine Reimportpflicht fuer vorhandene Felder.
Nachpruefung 2026-09-26: Der Nutzer sah den Referenzbereich in einer bereits geoeffneten Clientansicht nicht. Frontend auf 5173 und Produktionsbundle auf 4173 enthielten die Funktion; nach Strg+F5 bestaetigte der Nutzer die Sichtbarkeit. Ergaenzter synthetischer Interaktionstest oeffnet den Client ueber Auswahl/Manage, prueft den Bereich unter den Stammdaten und verarbeitet per Button exakt den Client ueber alle Timeslots. Zusammen mit den zwei vorhandenen Referenz-UI-Tests bestanden (3/3). Kein Produktionscode geaendert, daher Version weiterhin 2.8.0. Bestehende Scans wurden bei dieser Pruefung nicht nachverarbeitet.
## 8. Aktuell empfohlene Umsetzungsetappe ## 8. Aktuell empfohlene Umsetzungsetappe
2026-09-26 - Phase 13D / Phase 13C Darstellung (2.8.3, umgesetzt und verifiziert): Catalog references startet bei aktuellem Client eingeklappt; gruen vor Ueberschrift und aktuellem/erfolgreichem Status, orange und automatisch offen bei Handlungsbedarf oder Fehler. Pruefung, Verarbeitung und leere Clients bleiben neutral. Katalogversionen bleiben eingeklappt sichtbar, Erfolg nach Update bleibt offen, manuelles Umschalten funktioniert per Maus und Tastatur. Timeslots-Zeile mit gleichem Abstand oben/unten und gemeinsam zentrierten Controls. Purple-Knight-Timeslots benennen Active Directory, Entra ID oder kombinierten Scope aus bestehenden kanonischen Namen bzw. exakt lauf-/dateigebundenen gespeicherten Metadaten/Umgebungsbeobachtungen. Ohne Scopebeleg bleibt der Originalname; keine Vermutung aus Dateinamen oder fremden Scans, keine Persistenz-/Importer-/Scoringaenderung und keine neue Reimportpflicht. 36 gezielte Interaktions-/Layout-/Versionsvertragstests, beide Produktionsbuilds, Local-only-/Designcheck und synthetische Dark-/Light-Sicht- und Tastaturpruefung bestanden. App und Backend gesund auf 2.8.3 mit kanonischer PostgreSQL. Patchversion fuer zusammengehoerige UI-Korrekturen; Phase 13D bleibt in Arbeit. Die bekannte Gesamtsuite-Baseline mit sechs fachfremden Fehlern wurde nicht erneut ausgefuehrt.
2026-09-26 - Phase 13D Handlungstexte (2.8.2, umgesetzt und verifiziert): Allgemeiner Erklaerabsatz im linken Catalog-references-Bereich entfernt. Please update benennt Handlungsbedarf, Up to date / No action needed. den aktuellen Stand, Update successful den per erneutem Statusabruf bestaetigten Erfolg. Button heisst Update references; ATT&CK-/D3FEND-Versionen sowie Rot-bei-Bedarf/Gelb-sonst bleiben. Leere Clients, laufende Pruefung und Fehler erhalten eigene Texte; bei verbleibender Arbeit wird kein Erfolg behauptet. 25 gezielte UI-/Versionsvertragstests, beide Produktionsbuilds, Local-only-/Designcheck und synthetische Light-/Dark-Pruefung bestanden. Patchversion fuer reine UI-Korrektur, keine reale Nachverarbeitung oder Persistenzaenderung; Phase 13D bleibt in Arbeit. Die bekannte Gesamtsuite-Baseline mit sechs fachfremden Fehlern wurde fuer diese Textkorrektur nicht erneut ausgefuehrt.
Nachtrag Phase 13D, 2026-09-26, Version 2.8.1 (Nutzerkorrekturen umgesetzt und verifiziert): Die Clientuebersicht beruecksichtigt jetzt ausstehende Referenzerkennung sowie veraltete Erkenner-/Katalogstaende mit gelbem Statuspunkt und hervorgehobenem Manage, auch ohne Clientauswahl. Echte Erkennungsfehler sind rot; unbekannter Status bleibt als Pruefhinweis sichtbar und bietet einen Status-Retry. Bestehende Import-/GPO-Fehler behalten Vorrang. Statusabrufe laufen unabhaengig von den Uebersichtsdaten mit maximal drei gleichzeitigen Anfragen und ohne neues Polling; Daten-/Import- und erfolgreiche Katalogupdates aktualisieren sie. Ein neuerer Panelstatus gewinnt gegen verspätete Uebersichtsantworten.
Zusaetzliche explizite Nutzerentscheidung: Catalog references steht links direkt ueber Timeslots, auch bei Stammdatenbearbeitung. Recognize references ist bei Nachverarbeitungs- oder Wiederholungsbedarf rot, sonst standardgelb; dies ist eine begrenzte Ausnahme der allgemeinen Aktionsfarbe. Die linke Quellnavigation bleibt unterhalb des Clientkopfs scrollbar. Nach Erfolg aktualisieren sich Karte und Workspace. Risiken/Grenzen: Ausfuehrungsfehler sind aktueller UI-Zustand; nach erneutem Laden zeigt die persistierte Bereitschaft weiterhin den ausstehenden Lauf, ohne einen gespeicherten Hintergrundjob vorzutäuschen. Keine reale Client-Nachverarbeitung, Migration oder Scoring-/Importsemantikaenderung.
Verifikation: 25 gezielte Status-/UI-/Readiness-Tests; nach der Platzierungskorrektur 23 UI-/Layout-/Versionsvertragstests bestanden, einschliesslich sieben Tests fuer den Clientstatus und den tatsaechlichen Manage-/Nachverarbeitungs-/Rueckkehrpfad. Vollsuite waehrend dieses Batches: 1582 bestanden, 14 uebersprungen, dieselben sechs bestehenden Fehler in fuenf Dateien. Finale Client-/Serverbuilds, Local-only-, Design- und Diffpruefung bestanden. Synthetische Dark-/Light-Sichtpruefung bestaetigt Statusfarben, linke Platzierung, roten/gelben Button und Scrollbarkeit bei kleiner Fensterhoehe. App/Backend melden 2.8.1, HTTP 200 und kanonische PostgreSQL. Versionsentscheidung: rueckwaertskompatible Korrektur als Patch; Phase 13D bleibt fuer ihren weiteren Umfang offen.
Ergaenzung 2026-09-11 fuer den Timeslotvergleich: Nach dem jetzt dokumentierten Planauftrag beginnt eine spaeter beauftragte Implementierung mit P0/P1 aus [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md), danach folgen P2-P6 in Abhaengigkeitsreihenfolge. Zuerst Vergleichsidentitaet und Gegenpruefung absichern, dann offene Verpflichtungen und A/B-Scorebasis, zuletzt die Oberflaeche. Dieser Dokumentationsauftrag startet keine Umsetzung und unterbricht keine unabhaengigen Phase-13B-Arbeiten. Die folgenden aelteren Empfehlungen bleiben als Projektkontext erhalten. Ergaenzung 2026-09-11 fuer den Timeslotvergleich: Nach dem jetzt dokumentierten Planauftrag beginnt eine spaeter beauftragte Implementierung mit P0/P1 aus [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md), danach folgen P2-P6 in Abhaengigkeitsreihenfolge. Zuerst Vergleichsidentitaet und Gegenpruefung absichern, dann offene Verpflichtungen und A/B-Scorebasis, zuletzt die Oberflaeche. Dieser Dokumentationsauftrag startet keine Umsetzung und unterbricht keine unabhaengigen Phase-13B-Arbeiten. Die folgenden aelteren Empfehlungen bleiben als Projektkontext erhalten.
Die naechste Umsetzung fuehrt Phase 13B auf dem konsolidierten Import-, Policy-Analyse- und Orchestrierungsunterbau fort: Die naechste Umsetzung fuehrt Phase 13B auf dem konsolidierten Import-, Policy-Analyse- und Orchestrierungsunterbau fort:
@ -2328,6 +2379,8 @@ Abnahme 2026-08-23: 677 Standardtests bestanden, 8 optionale PostgreSQL-Integrat
## 10. Offene Entscheidungen ## 10. Offene Entscheidungen
Autonome Client-Datenbanken 2026-09-26: Das Zielbild "eine fachliche Datenbank pro Client" und die autarke Wiederherstellung einschliesslich verwendeter Referenzstaende sind entschieden; Implementierung und Migration sind nicht beauftragt. Vor einer Umsetzung sind Client-Routing und Autorisierung, Ort und Lebenszyklus des zentralen Katalogs, Bindung und Pruefsummen der Referenzartefakte, Backup-/Restore-Paketformat, Lizenz-/Bezugsgrenzen der Microsoft-Pakete, clientindividuelle Overrides, Update-/Rollback-Ablauf und Migration des bestehenden kanonischen Bestands zu spezifizieren und mit synthetischen Isolation-, Historien- und Offline-Restore-Tests abzusichern. Die genaue SSO-, Rollen- und Betriebsplattform bleibt offen.
Bericht A/B 2026-09-23: Die Nutzeranforderung beauftragt die HTML-Erzeugung direkt aus Client Info und ordnet die UI-Anbindung Phase 7 zu. Die veroeffentlichten Report-Inhalte bleiben durch `docs/REPORT_STRATEGY.md` gebunden. Offen ist die endgueltige Vollstaendigkeitsabnahme des Technical Reports ueber alle Quellformate und Reimport-/Diagnostic-Klassen; sie wird nicht aus dem Vorhandensein des Buttons abgeleitet. Bericht A/B 2026-09-23: Die Nutzeranforderung beauftragt die HTML-Erzeugung direkt aus Client Info und ordnet die UI-Anbindung Phase 7 zu. Die veroeffentlichten Report-Inhalte bleiben durch `docs/REPORT_STRATEGY.md` gebunden. Offen ist die endgueltige Vollstaendigkeitsabnahme des Technical Reports ueber alle Quellformate und Reimport-/Diagnostic-Klassen; sie wird nicht aus dem Vorhandensein des Buttons abgeleitet.
Timeslotvergleich 2026-09-11: P0/P1 muessen pro Scanner/Format/GPO die belastbaren Objekt-/Scopefelder, Vergleichsgranularitaet und Negativnachweisfaehigkeit sowie die konkrete Reimportbetroffenheit bestimmen. Speicher-/API-Vertrag und Uebergang fuer offene Verpflichtungen und auditierte Scopeentscheidungen sind vor Migration festzulegen. Native Teilpopulationen duerfen nur mit separat belegten Beitraegen verglichen werden; ein zusaetzlicher risikogewichteter Expositionsindex ist nicht freigegeben. Siehe Detailentscheidungen in [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md). Timeslotvergleich 2026-09-11: P0/P1 muessen pro Scanner/Format/GPO die belastbaren Objekt-/Scopefelder, Vergleichsgranularitaet und Negativnachweisfaehigkeit sowie die konkrete Reimportbetroffenheit bestimmen. Speicher-/API-Vertrag und Uebergang fuer offene Verpflichtungen und auditierte Scopeentscheidungen sind vor Migration festzulegen. Native Teilpopulationen duerfen nur mit separat belegten Beitraegen verglichen werden; ein zusaetzlicher risikogewichteter Expositionsindex ist nicht freigegeben. Siehe Detailentscheidungen in [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md).
@ -2345,7 +2398,7 @@ Festgelegte Fachentscheidung vom 2026-09-06: Ein GPO-Benchmark darf nur dann `Pa
- Bezugs-, Lizenz- und Freigabeverfahren für versionierte Microsoft-Baselines und ADMX-/ADML-Metadatenkataloge - Bezugs-, Lizenz- und Freigabeverfahren für versionierte Microsoft-Baselines und ADMX-/ADML-Metadatenkataloge
- optionaler Import von `Get-GPOReport`-, Group-Policy-Results-, `gpresult`- oder anderer RSoP-Evidenz in einer späteren Ausbaustufe - optionaler Import von `Get-GPOReport`-, Group-Policy-Results-, `gpresult`- oder anderer RSoP-Evidenz in einer späteren Ausbaustufe
- genaue SSO-Integration im Unternehmensnetz - genaue SSO-Integration im Unternehmensnetz
- Rollen- und Mandantenmodell - SSO-gebundene Rollen, Berechtigungen und Client-Routing innerhalb des entschiedenen Eine-Datenbank-pro-Client-Zielbilds
- Aufbewahrungs- und Löschfristen - Aufbewahrungs- und Löschfristen
- betriebliche Anpassung der festgelegten Importgrenzen an die Firmeninfrastruktur - betriebliche Anpassung der festgelegten Importgrenzen an die Firmeninfrastruktur
- Umgang mit manuell bestätigten Mappings zwischen Projekten - Umgang mit manuell bestätigten Mappings zwischen Projekten
@ -2355,6 +2408,8 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 11. Risiken ## 11. Risiken
- 2026-09-26, autonome Client-Datenbanken: Eine getrennte Datenbank allein garantiert keine Autarkie, wenn Baseline-/MITRE-Versionen oder GPO-/Baseline-Originale nur zentral verfuegbar sind. Backup, Restore und Loeschung muessen Datenbank und private Artefakte gemeinsam behandeln. Zentrale Referenzupdates duerfen historische Urteile nicht still neu berechnen und ein Clientwechsel darf keine Datenbank oder Artefakte eines anderen Clients erreichen. Die bisher globale Baseline-Liste und der serverweite MITRE-Katalog sind noch nicht clientgebunden; ihre blosse Verschiebung in der UI waere keine Isolation. Ein Migrationskonzept mit verifiziertem Backup ist vor jeder Bestandsaenderung erforderlich. Diese Risiken sind als Planungsaufgaben offen; keine Implementierung oder Abnahme wird behauptet.
- 2026-09-23, HTML-Reports: Die Management-Datei darf keine internen Source-IDs, Assets oder technischen Rohwerte enthalten, auch wenn beide Renderings dieselbe interne Projektion nutzen. Separate Dateien und ein synthetischer Leckagetest sichern die Grenze. Der Technical Report kann nur gespeicherte Originalfelder und GPO-Anweisungen zeigen; fehlende Ist-/Sollstruktur, Source-Evidence oder Parserdiagnose wird nicht erfunden. Ein umfassender Vollstaendigkeits- und Theme-Test im freigegebenen Dummy ist noch offen; bis dahin gilt die A/B-Abnahme nicht als abgeschlossen. - 2026-09-23, HTML-Reports: Die Management-Datei darf keine internen Source-IDs, Assets oder technischen Rohwerte enthalten, auch wenn beide Renderings dieselbe interne Projektion nutzen. Separate Dateien und ein synthetischer Leckagetest sichern die Grenze. Der Technical Report kann nur gespeicherte Originalfelder und GPO-Anweisungen zeigen; fehlende Ist-/Sollstruktur, Source-Evidence oder Parserdiagnose wird nicht erfunden. Ein umfassender Vollstaendigkeits- und Theme-Test im freigegebenen Dummy ist noch offen; bis dahin gilt die A/B-Abnahme nicht als abgeschlossen.
- 2026-09-23, Environment-Unterabschnitte: Ein generischer Tabellenname und eine darueber stehende Abschnittsueberschrift beweisen nicht dieselbe fachliche Gruppe. Nur ein eindeutiger, exakt quellengebundener Unterabschnitt benennt die Tabelle neu; bei Konflikten bleibt ihr nativer Name stehen. Eine aus dem ersten Unterabschnitt uebernommene breite Abschnittsbeschriftung darf nicht andere Tabellen falsch vereinnahmen. Die synthetischen Regressionen sichern den Klickpfad, Dateiscope und Konfliktfall; realer Kundenscope wurde nicht gelesen. Schmale Fenster behalten interne horizontale Tabellenbewegung ohne Abschneiden von Spalten. - 2026-09-23, Environment-Unterabschnitte: Ein generischer Tabellenname und eine darueber stehende Abschnittsueberschrift beweisen nicht dieselbe fachliche Gruppe. Nur ein eindeutiger, exakt quellengebundener Unterabschnitt benennt die Tabelle neu; bei Konflikten bleibt ihr nativer Name stehen. Eine aus dem ersten Unterabschnitt uebernommene breite Abschnittsbeschriftung darf nicht andere Tabellen falsch vereinnahmen. Die synthetischen Regressionen sichern den Klickpfad, Dateiscope und Konfliktfall; realer Kundenscope wurde nicht gelesen. Schmale Fenster behalten interne horizontale Tabellenbewegung ohne Abschneiden von Spalten.
@ -2675,6 +2730,22 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
## 12. Änderungsprotokoll ## 12. Änderungsprotokoll
2026-09-26 - Phase 6/7 / Scanner-Einstieg, CIS-Abschnitte, Work-Zusammenfuehrung (2.8.6, umgesetzt und verifiziert): Purple-Knight-Umfangsnamen loesen ueber Profil-`runNames` auf; Maester-Abschnitt `Remediation action` als Next step; PowerShell-Skripte nach `script ...:` bzw. mit Variablenzuweisung als Befehlsblock mit Anzeige-Zeilenumbruechen; Purple-Knight-Likelihood ohne angehaengte Referenzen; CIS-Abschnittsnamen aus dem eigenen Scanlauf in `CIS Category`, Work-Ueberschriften und Plaketten-Tooltips; `withRebuiltLayout` erhaelt beim GPO-Anhang alle Datensatzmetadaten. Patchversion, kein Reimport; Details im Statusnachtrag oben.
2026-09-26 - Phase 13D / gemischte Work-Projektion (2.8.5, umgesetzt und verifiziert): Beim Anhaengen von GPO-Ergebnissen an All Records wird referenceRecognition jetzt vom jeweiligen Scannerbefund uebernommen. Zuvor blieben die Referenzlaeufe gespeichert, gingen aber beim Neuaufbau der Reader-Projektion verloren. Die Uebernahme bewahrt Referenzen, Quellenherkunft, Erkennerversion, Lauf und Katalogversion/-hash einschliesslich bewusst leerer Erkennung; GPOs und nicht indexierte Befunde erhalten keine fremden Referenzen. Synthetische Regression oeffnet Referenzdetails im echten FindingReader mit und ohne GPOs. 30 fokussierte Referenz-/GPO-/CIS-Tests und 9 UI-/API-Versionsvertragstests, beide Produktionsbuilds sowie Design-/Local-only- und Diffpruefung bestanden. Patchversion fuer rueckwaertskompatible Projektionskorrektur; keine Datenmutation und kein Reimport bzw. neuer Erkennungslauf erforderlich. Bereits geladene Workspaces muessen neu geladen werden. Phase 13D bleibt in Arbeit.
2026-09-26 - Phase 7 / Finding-Reader-Abstaende (2.8.4, umgesetzt und verifiziert): Die gemeinsame Abschnittsregel umfasst jetzt div und section, damit GPO-Quellen und Baseline-Vergleich denselben Tokenabstand verwenden: space-4 (16 px) vor/nach der Trennlinie, space-2 (8 px) zwischen Feldueberschrift und Text. Redundante Abschlusslinien am letzten Quellblock und letzten Reader-Feld entfernt; Problem-/Next-step-Sonderabstaende bleiben erhalten. Synthetischer permanenter Klicktest prueft den gerenderten GPO-Reader vor, nach und nach erneutem Schliessen des Baseline-Disclosures. 44 gezielte Reader-/UI-/API-Versionsvertragstests, beide Produktionsbuilds, Design-/Local-only- und Diffpruefung bestanden. Synthetische Light-/Dark-Sichtpruefung bestaetigt jeweils rund 16 px Abschnittsabstand. Patchversion fuer die gemeinsame Darstellungskorrektur; keine Daten-, Import-, Bewertungs- oder Persistenzaenderung. Risiko: Die gemeinsame Regel wirkt auch auf weitere native Reader-Abschnitte; fokussierte Reader-Regressionen decken die bestehenden Faelle ab. Phasenstatus unveraendert.
2026-09-26 - Phase 13D / Phase 13C Darstellung (2.8.3, umgesetzt und verifiziert): Catalog references startet bei aktuellem Client eingeklappt; gruen vor Ueberschrift und aktuellem/erfolgreichem Status, orange und automatisch offen bei Handlungsbedarf oder Fehler. Pruefung, Verarbeitung und leere Clients bleiben neutral. Katalogversionen bleiben eingeklappt sichtbar, Erfolg nach Update bleibt offen, manuelles Umschalten funktioniert per Maus und Tastatur. Timeslots-Zeile mit gleichem Abstand oben/unten und gemeinsam zentrierten Controls. Purple-Knight-Timeslots benennen Active Directory, Entra ID oder kombinierten Scope aus bestehenden kanonischen Namen bzw. exakt lauf-/dateigebundenen gespeicherten Metadaten/Umgebungsbeobachtungen. Ohne Scopebeleg bleibt der Originalname; keine Vermutung aus Dateinamen oder fremden Scans, keine Persistenz-/Importer-/Scoringaenderung und keine neue Reimportpflicht. 36 gezielte Interaktions-/Layout-/Versionsvertragstests, beide Produktionsbuilds, Local-only-/Designcheck und synthetische Dark-/Light-Sicht- und Tastaturpruefung bestanden. App und Backend gesund auf 2.8.3 mit kanonischer PostgreSQL. Patchversion fuer zusammengehoerige UI-Korrekturen; Phase 13D bleibt in Arbeit. Die bekannte Gesamtsuite-Baseline mit sechs fachfremden Fehlern wurde nicht erneut ausgefuehrt.
2026-09-26 - Phase 13D Handlungstexte (2.8.2, umgesetzt und verifiziert): Allgemeiner Erklaerabsatz im linken Catalog-references-Bereich entfernt. Please update benennt Handlungsbedarf, Up to date / No action needed. den aktuellen Stand, Update successful den per erneutem Statusabruf bestaetigten Erfolg. Button heisst Update references; ATT&CK-/D3FEND-Versionen sowie Rot-bei-Bedarf/Gelb-sonst bleiben. Leere Clients, laufende Pruefung und Fehler erhalten eigene Texte; bei verbleibender Arbeit wird kein Erfolg behauptet. 25 gezielte UI-/Versionsvertragstests, beide Produktionsbuilds, Local-only-/Designcheck und synthetische Light-/Dark-Pruefung bestanden. Patchversion fuer reine UI-Korrektur, keine reale Nachverarbeitung oder Persistenzaenderung; Phase 13D bleibt in Arbeit. Die bekannte Gesamtsuite-Baseline mit sechs fachfremden Fehlern wurde fuer diese Textkorrektur nicht erneut ausgefuehrt.
2026-09-26 - Phase 13D Status-/Bedienkorrektur (2.8.1): Referenzbereitschaft in Client-Punkt/Manage aufgenommen, asynchron begrenzt geladen und nach Katalog-/Datenupdates sowie Nachverarbeitung aktualisiert. Catalog references auf Nutzerwunsch links ueber Timeslots; Aktionsbutton rot bei Bedarf, sonst gelb. Synthetische Klickpfade, beide Themes und finales Buildpaar verifiziert; bekannte sechs Gesamtsuitefehler unveraendert. Keine echte Client-Nachverarbeitung. Siehe Nachtrag in Abschnitt 8.
2026-09-26 - Phase 13D erster Umfang (2.8.0): ATT&CK 19.2/D3FEND 1.6.0, versionierter frameworkweiter Erkenner, append-only PostgreSQL-Referenzlaeufe und immutable Katalog-Snapshots samt Export, atomare Ableitung im normalen Batchimport, explizite Client-Nachverarbeitung, Pathfinder-/Reader-Anbindung. Migration 023 nach verifiziertem Backup. Historischer Projektexport ergaenzt auch ersetzte Scannerlaeufe und deren Originalfelder, damit alte Referenzlaeufe keine verwaisten Quellen erhalten; durch synthetischen Scanersatz gegen PostgreSQL verifiziert. 48 fokussierte Tests und separat PostgreSQL-Pruefung bestanden; anschliessende Versions-/API-/Reader-Verifikation ebenfalls bestanden. Gesamtsuite: 1569 bestanden, 14 uebersprungen, dieselben 6 vorherigen Fehlschlaege in 5 Dateien (GPO-Evaluation-Visibility 2, Security-Domain-Interaktion 1, AppLocker-HTML-Pruefung 1, Result-DB-Scope-Quelltextpruefung 1, Dashboard-Cache-Quelltextpruefung 1). Keine neu aufgetretenen Testfehler. Client-/Serverbuild, Local-only- und Designcheck bestanden; synthetische Light-/Dark-Sichtpruefung. Bestehende Kundenscans nicht automatisch nachverarbeitet. App/Backend wieder auf 5173/4173 mit kanonischer PostgreSQL gestartet. Die weitergehende Phase 13D und die geplante Client-Datenbanktrennung bleiben offen.
2026-09-26: Nutzerentscheidung fuer das spaetere Zielbild autonomer Client-Datenbanken aufgenommen. Zentrale, unveraenderlich versionierte MITRE-/Microsoft-Referenzkataloge bleiben von clientgebundener Auswahl und Auswertung getrennt; verwendete Referenzbytes gehoeren zur autarken Client-Sicherung. Phase 14 mit Vorarbeiten an Repository-/Artefaktgrenzen, offene Detailentscheidungen und Risiken dokumentiert. Reine Planfortschreibung: keine Anwendungsaenderung, Datenbank-/Schema-/Artefaktmigration, Tests oder Produktversionsaenderung; bestehender Phasenstatus bleibt unveraendert.
2026-09-23: Client Info erhielt Management Report und Technical Report als getrennte HTML-Downloadaktionen. Beide lesen den ausgewaehlten Timeslot vollstaendig ueber die API und ignorieren den momentanen Source-Filter. Report-Projektion 1.1.0 traegt interne technische Fakten nur fuer den Technical-Cut; der Management-Renderer gibt keine Assets aus. Der neue Technical-Renderer listet gespeicherte Originalfelder, GPO-Quellanweisungen, Ist-/Sollwerte, Provenienz, Inventory, Diagnostics und Result-Index; belegte Felddifferenzen bleiben feldspezifisch. Synthetische Klickpfad-/Renderer-Regressionen und beide Produktionsbuilds bestanden; Vollstaendigkeits-/Theme-Abnahme bleibt offen. Kein Kundendatenzugriff oder Bestandsmutation. Minorversion 2.6.0. 2026-09-23: Client Info erhielt Management Report und Technical Report als getrennte HTML-Downloadaktionen. Beide lesen den ausgewaehlten Timeslot vollstaendig ueber die API und ignorieren den momentanen Source-Filter. Report-Projektion 1.1.0 traegt interne technische Fakten nur fuer den Technical-Cut; der Management-Renderer gibt keine Assets aus. Der neue Technical-Renderer listet gespeicherte Originalfelder, GPO-Quellanweisungen, Ist-/Sollwerte, Provenienz, Inventory, Diagnostics und Result-Index; belegte Felddifferenzen bleiben feldspezifisch. Synthetische Klickpfad-/Renderer-Regressionen und beide Produktionsbuilds bestanden; Vollstaendigkeits-/Theme-Abnahme bleibt offen. Kein Kundendatenzugriff oder Bestandsmutation. Minorversion 2.6.0.
2026-09-23: Environment-Results-Tabellenkoepfe berichtigt (Phase 7/13C). Eindeutige Quell-Unterabschnittslabels werden tabellengenau aus behaltenen Beobachtungen derselben Lauf-/Datei-/Abschnittsidentitaet gelesen. `Honey Pot` steht nicht mehr als Sammelueberschrift ueber `Account analysis`; generische `Tabelle n`- und sichtbare `Source table index`-Zeilen entfallen. Zweispaltige Label-Wert-Metadaten und horizontal zusammenbleibende native Kopf-/Datenzellen verbessern die schmale Ansicht. 32 fokussierte synthetische Tests sowie beide Produktionsbuilds, Local-only- und Designcheck bestanden; keine Bestandsmutation oder Kundeninspektion. Patchversion 2.5.15. 2026-09-23: Environment-Results-Tabellenkoepfe berichtigt (Phase 7/13C). Eindeutige Quell-Unterabschnittslabels werden tabellengenau aus behaltenen Beobachtungen derselben Lauf-/Datei-/Abschnittsidentitaet gelesen. `Honey Pot` steht nicht mehr als Sammelueberschrift ueber `Account analysis`; generische `Tabelle n`- und sichtbare `Source table index`-Zeilen entfallen. Zweispaltige Label-Wert-Metadaten und horizontal zusammenbleibende native Kopf-/Datenzellen verbessern die schmale Ansicht. 32 fokussierte synthetische Tests sowie beide Produktionsbuilds, Local-only- und Designcheck bestanden; keine Bestandsmutation oder Kundeninspektion. Patchversion 2.5.15.
@ -2798,6 +2869,8 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
| Datum | Änderung | | Datum | Änderung |
| 2026-09-26 | Phase 7: MITRE-Onlineaktualisierung aus offizieller Enterprise-STIX-Quelle, separater Download der originalen MITRE-Datei und weiterhin manueller JSON-Import. Gemeinsam gespeicherter privater Katalog, sofortige Graphaktualisierung, Same-Origin-API; keine Assessment-/DB-Migration. Minorversion 2.7.0; synthetische Service-/HTTP-/UI-Tests, Live-Update und Originaldownload, Design-/Local-only-Pruefung sowie beide Builds verifiziert. Sechs vorbestehende fachfremde Tests bleiben offen. |
| 2026-09-26 | Phase 7: MITRE-Standardkatalog v19.2 aus offiziellem STIX, sichtbarer STIX-/JSON-Upload im Data Hub und sofortige Pathfinder-Aktualisierung bei Upload/Reset. Reproduzierbare Quelle und Browser-Override-Grenze in `docs/MITRE_CATALOG.md`; Patchversion 2.6.47. 21 fokussierte Tests, Data-Hub-Layouttest, Design-/Local-only-Pruefung und beide Builds bestanden; Gesamtsuite mit sechs fachfremden bestehenden Fehlern. |
| 2026-09-25 | Phase 7: Pathfinder-Detailswerte erhalten eine breite flexible Spalte ohne erzwungene Wort- und Ziffernumbrueche; lange technische IDs bleiben innerhalb ihres Bereichs scrollbar. 14 fokussierte Tests, Design-/Local-only-Pruefung und beide Builds bestanden; Patchversion 2.6.46 am kanonischen PostgreSQL bereitgestellt. | | 2026-09-25 | Phase 7: Pathfinder-Detailswerte erhalten eine breite flexible Spalte ohne erzwungene Wort- und Ziffernumbrueche; lange technische IDs bleiben innerhalb ihres Bereichs scrollbar. 14 fokussierte Tests, Design-/Local-only-Pruefung und beide Builds bestanden; Patchversion 2.6.46 am kanonischen PostgreSQL bereitgestellt. |
| 2026-09-25 | Phase 7: Redundanten DB-Structure-Einfuehrungsblock und den Erklaersatz unter `Data flow` entfernt. Schemaaktion, Kennzahlen, Ablauf und Popups bleiben erhalten. 17 fokussierte Tests, Design-/Local-only-Pruefung und beide Builds bestanden; Patchversion 2.6.45 am kanonischen PostgreSQL bereitgestellt. | | 2026-09-25 | Phase 7: Redundanten DB-Structure-Einfuehrungsblock und den Erklaersatz unter `Data flow` entfernt. Schemaaktion, Kennzahlen, Ablauf und Popups bleiben erhalten. 17 fokussierte Tests, Design-/Local-only-Pruefung und beide Builds bestanden; Patchversion 2.6.45 am kanonischen PostgreSQL bereitgestellt. |
| 2026-09-25 | Phase 7: DB-Structure-Datenflussbuttons oeffnen die ausgewaehlte Tabelle wieder im gemeinsamen Popup statt inline. Referenzierende Tabellen sind im Fenster navigierbar; Escape schliesst es. 16 fokussierte Tests, Design-/Local-only-Pruefung und beide Builds bestanden; Patchversion 2.6.44 am kanonischen PostgreSQL bereitgestellt. | | 2026-09-25 | Phase 7: DB-Structure-Datenflussbuttons oeffnen die ausgewaehlte Tabelle wieder im gemeinsamen Popup statt inline. Referenzierende Tabellen sind im Fenster navigierbar; Escape schliesst es. 16 fokussierte Tests, Design-/Local-only-Pruefung und beide Builds bestanden; Patchversion 2.6.44 am kanonischen PostgreSQL bereitgestellt. |

27
docs/MITRE_CATALOG.md Normal file
View File

@ -0,0 +1,27 @@
# Enterprise ATT&CK catalog in ASSESSOR
Pathfinder uses the compact, versioned default catalog at `src/assets/data/mitre-enterprise-v19.2.json`. It was generated from MITRE's official Enterprise ATT&CK STIX 2.1 bundle:
- Source: https://github.com/mitre-attack/attack-stix-data/blob/master/enterprise-attack/enterprise-attack-19.2.json
- Source SHA-256: `DC1639CAA5501D720E280CF1CBD8FBE009884A0C9B3E6E9ED9D0C25166C3D8F4`
- Collection version: 19.2; 15 active tactics, 697 active techniques and subtechniques, 1,057 active groups, campaigns and software entries.
To refresh the repo asset, download a **version-pinned** official Enterprise STIX bundle and run `node --experimental-strip-types scripts/update-mitre-asset.mts <path-to-bundle>`. Review the generated version, counts and graph tests before changing the default import. The converter follows the STIX matrix tactic order and omits revoked or deprecated objects and relationships.
The Data Hub has three actions:
- **Update from MITRE** fetches the official latest Enterprise STIX bundle from MITRE's `attack-stix-data` repository, validates and converts it, and activates it only when its collection version is newer than the active shared catalog. The compact result is saved atomically at `<ASSESSOR_ARTIFACT_ROOT>/reference-data/mitre-enterprise.json`, outside the public web root. The backend serves that catalog after restart, and Pathfinder receives an immediate update event in the current browser. The browser's custom override is cleared after a successful update check.
- **Download from MITRE** fetches the original latest Enterprise STIX JSON from that same MITRE source and offers it as `enterprise-attack-v<version>.json`. Downloading does not activate or persist it. The backend proxy keeps the browser same-origin and validates the source before sending it. The source is capped at 80 MiB and the request times out after 120 seconds.
- **Import JSON file** is the manual fallback. It accepts an official Enterprise STIX bundle or the compact ASSESSOR JSON format. A custom matrix is stored in browser `localStorage`, overrides the shared catalog only in that browser, and is shown as `Custom v<version>`. **Use active catalog** removes this override. The open Pathfinder graph updates after either action.
The shared catalog is read through `GET /api/v1/reference-data/mitre-attack`; the guarded `POST /api/v1/reference-data/mitre-attack/refresh` updates it, and `GET /api/v1/reference-data/mitre-attack/source` sends the latest validated original file. No PostgreSQL schema or existing assessment data is changed. MITRE availability is required for update and download; the bundled v19.2 catalog and manual import remain available offline. A shared catalog update does not remove another browser's deliberate local override.
## Reference recognition (2.8.0)
ATT&CK updates now also retain the complete versioned recognition catalog, including
historical entries, alongside the active graph in the same atomic file. Stored scanner
reference runs remain frozen until the explicit client action **Recognize references**.
The bundled D3FEND catalog and reference storage are documented in
`docs/REFERENCE_RECOGNITION.md`. Migration 023 adds this independent derived storage;
the update/download buttons themselves do not rewrite assessment data. Manual browser
matrix overrides remain graph fallbacks and do not select backend recognition catalogs.

View File

@ -0,0 +1,133 @@
# Phase 13D: ATT&CK and D3FEND references
## Scope and semantics
The initial implementation recognizes Enterprise ATT&CK technique/sub-technique IDs,
tactic IDs and unambiguous exact catalog titles, plus D3FEND defensive-technique IDs
and unambiguous exact titles. It processes retained `OriginalField` values using one
framework-specific engine, independently of the scanner. A framework-named header
declares its context; IDs in other fields are merely **mentioned**. Titles are only
matched in declared context. Whitespace and case normalization is allowed; fuzzy
matching, translations and LLM classification are not.
`active`, `deprecated`, `revoked` and `not-in-catalog` describe the lookup result.
An unknown ID is not claimed to be invalid. Placeholder text creates no reference.
An ambiguous title (for example PowerShell in current and historical ATT&CK entries)
does not select either ID. Unmatched original content remains available in the source
fields. No ATT&CK-to-D3FEND defensive inference or automatic correlation is performed.
The derived references never enter `FindingDefinition.frameworkReferences`, the
comparison identity engine, source guidance, severity, verdict, score, Security Domain,
review state or the external Result DB disclosure. Existing report-correlation semantics
remain separate. A shared reference describes a shared topic, not a proven common cause.
## Catalogs and reproducibility
- `attack-reference-v19.2.json`: 873 technique/tactic entries, including historical
deprecated and revoked entries. Source: MITRE's version-pinned Enterprise STIX 19.2.
- `d3fend-reference-v1.6.0.json`: 272 defensive technique entries (`D3-…`).
Source: <https://d3fend.mitre.org/ontologies/d3fend/1.6.0/d3fend.json>.
D3A/D3E entities and inferred cross-framework relationships are outside this slice.
- Each asset contains its original download URL, source SHA-256 and MITRE copyright/
license notice. Snapshot SHA-256 covers the complete canonical JSON catalog used by
recognition, including entries, version, provenance and notice.
- `scripts/update-reference-assets.mts` regenerates the compact assets from official
source files. Recognition and reprocessing require no external network.
- The existing **Update from MITRE** downloads ATT&CK and atomically stores both its
graph and complete recognition catalog. An equal-version refresh can fill in missing
recognition metadata from an older ASSESSOR installation. A central update makes a
new catalog available; it does not silently replace previous reference runs.
- D3FEND is bundled at 1.6.0 in this initial release; no D3FEND runtime update UI has
been added. **Download from MITRE** and manual **Import JSON file** remain ATT&CK
matrix actions. The manual browser override affects the displayed matrix and transient
previews; stored recognition always uses the backend's versioned catalogs.
## Storage and execution
Migration `023_reference_recognition` adds immutable public `assessor_reference_catalogs`,
append-only client/assessment/scan-bound `assessor_reference_runs` and their catalog
membership table. 021/022 are already reserved in the canonical local datastore by
timeslot-comparison and obligation work; those migrations are not modified here.
No separate client databases are created in this phase.
The normal browser batch-import transaction includes its reference run and catalog
snapshots. A recognition failure therefore rolls back the batch instead of reporting a
partially completed import. The low-level staging API and pre-existing scans can be
indexed with the explicit client action below; the UI never silently fabricates a stored
run for them.
**Data Hub → client → Catalog references → Update references** processes all current
scanner runs in that client across its timeslots, from their retained fields. The button
shows available catalog versions and pending scan count. Its guarded endpoint is
`POST /api/v1/projects/:projectId/references/recognize`; status is available through
`GET /api/v1/projects/:projectId/references`. Concurrent requests for one client share
one job; unchanged input, catalogs and recognizer version are idempotent and do not
bump repository revision. Individual scans are committed atomically; after interruption,
retry skips scans already completed. This initial synchronous route is not a background
job scheduler; very large clients can require a later queued implementation.
Each result retains occurrence and file identity, source file hash if available, source
position, field key/label/path, JSON value pointer, exact matched substring and offsets.
Offsets are zero-based UTF-16 indices into the retained `rawValue`, when present, or the
individual string leaf of `value`; the end is exclusive. Each run retains recognizer
version, timestamp, source-input hash and exact catalog hashes. Older runs remain
exportable. Project export includes every referenced compact catalog snapshot, allowing
reference history to be read without the central catalog. Original full STIX/JSON-LD
downloads are not needed to replay recognition against these exact compact inputs.
## Presentation and boundaries
The Data Hub client overview includes reference readiness across every current timeslot.
Pending recognition or an outdated recognizer/catalog produces the existing yellow
update point and highlighted **Manage**, including before selecting the client. An
unavailable status remains a review notice; checking is neutral and never claims readiness.
An actual recognition failure is red. Existing scanner/GPO failures retain priority.
Status summaries load separately from overview metadata with at most three concurrent
requests, refresh on import/data changes and successful catalog updates, and reject stale
responses after a newer panel result. There is no added polling or persistent job state.
**Catalog references** is in the left client navigation immediately above **Timeslots**,
including while editing client master data. By explicit user decision on 2026-09-26,
**Update references** is red when work or retry is needed and yellow otherwise.
The generic explanation is omitted. The first line says **Please update**, **Up to date**
with **No action needed.**, or **Update successful** after execution and a fresh status
check confirms no remaining work. ATT&CK and D3FEND versions stay visible. Empty clients,
loading and failures use explicit separate messages and never claim success.
The section starts collapsed for a current client and opens automatically for pending
work or a failed status/update. Small green dots precede the heading and confirmed
current/success state; required action uses orange and unknown/empty/processing states
stay neutral. Catalog versions remain outside the disclosure and visible while collapsed.
After updating, the success message remains open; manual and keyboard toggling is retained.
Successful recognition updates the client card and its loaded workspace. A failed status
request offers **Retry status**. The navigation scrolls as one area below the client header
so the added controls do not make timeslots inaccessible on short screens.
Pathfinder consumes persisted active references and frozen tactic membership, with
declared ATT&CK mapped as native and mentioned ATT&CK mapped as derived. Unknown,
revoked and deprecated matches are visible in the Finding Reader but do not form active
graph relations. Unindexed stored occurrences remain unmapped until explicitly indexed.
Transient, unsaved previews use the same pure recognizer with their selected matrix.
Original raw references remain readable. The Finding Reader adds a provenance disclosure
after explanatory content; its Problem/Next step, Results and work tracking are unchanged.
Broader 13D work (other frameworks, profile-declared ambiguous contexts, standalone
reference search/filtering, technical identity resolution, correlation UI, catalog-version
selection and per-client database migration) remains planned separately.
## Verification
Synthetic domain, full batch HTTP import, client-button/Reader interaction and Pathfinder
tests cover declared/mentioned evidence, unknown IDs, exact offsets, nested values,
historical/ambiguous entries, client isolation, idempotence, source immutability and
catalog updates without retrospective changes. The opt-in PostgreSQL test uses only
generated synthetic IDs in the canonical datastore and deletes that exact test project.
No tenant report contents are inspected.
Verified release: **2.8.0**. Both production builds, UI/API version contract, local-only
and design checks pass. Synthetic Light/Dark checks include a 480px Reader. The full
suite has 1,569 passing and 14 skipped tests, with six pre-existing failures across
GPO evaluation visibility (2), Security Domain navigation (1), AppLocker markup (1),
Result DB scope source-text expectations (1) and dashboard cache source-text expectations
(1). Their root causes are not all resolved by this separate reference implementation.
The PostgreSQL integration check additionally verifies source-complete history export
after a synthetic scan replacement. No existing customer scans were reprocessed.

View File

@ -67,3 +67,12 @@ Structured Accepted Risk decisions retain every original revision and its target
When a match is absent or ambiguous, the old review remains on the retained previous scan. The replacement records an unresolved count and Data Hub shows a persistent review-required state. The reimport is still auditable and does not silently discard or guess customer data. When a match is absent or ambiguous, the old review remains on the retained previous scan. The replacement records an unresolved count and Data Hub shows a persistent review-required state. The reimport is still auditable and does not silently discard or guess customer data.
Both direct reimport and adoption of an already imported byte-identical replacement execute review transfer and scan supersession in the same repository transaction. The exact source SHA-256 multiset, assessment, and scanner must match before replacement is accepted. Both direct reimport and adoption of an already imported byte-identical replacement execute review transfer and scan supersession in the same repository transaction. The exact source SHA-256 multiset, assessment, and scanner must match before replacement is accepted.
# Reference derivation (Phase 13D, 2026-09-26)
ATT&CK/D3FEND recognition is an independent, versioned derivation over retained original
fields. It never alters canonical Finding identity, scanner outcome, source mapping or
reviews. Catalog or recognizer changes therefore use **Recognize references** in the
client's Data Hub and append a new derivation; they do not require reuploading source
files. Historical reference runs and their exact catalog snapshots remain retained.
If an extractor never retained a required source field, recognition cannot recover it;
the existing extractor-version/reimport policy still applies in that case.

4
package-lock.json generated
View File

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

View File

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

View File

@ -0,0 +1,10 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { convertEnterpriseStix } from '../src/features/mitre-graph/mitre-stix.ts';
const input = process.argv[2];
if (!input) throw new Error('Usage: node --experimental-strip-types scripts/update-mitre-asset.mts <official Enterprise ATT&CK STIX JSON>');
const graph = convertEnterpriseStix(JSON.parse(await readFile(input, 'utf8')));
const output = resolve(`src/assets/data/mitre-enterprise-v${graph.version}.json`);
await writeFile(output, JSON.stringify(graph) + '\n');
console.log(`${output}: ${graph.tacticOrder.length} tactics, ${graph.techniques.length} techniques, ${graph.entities.length} entities`);

View File

@ -0,0 +1,15 @@
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { attackReferenceCatalog, d3fendReferenceCatalog } from '../src/references/catalog-converters.ts';
const [attackPath, d3fendPath] = process.argv.slice(2);
if (!attackPath || !d3fendPath) throw new Error('Usage: node --experimental-strip-types scripts/update-reference-assets.mts <Enterprise STIX> <D3FEND JSON-LD>');
for (const [path, convert, sourceUrl, name] of [
[attackPath, attackReferenceCatalog, 'https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack-19.2.json', 'attack-reference'],
[d3fendPath, d3fendReferenceCatalog, 'https://d3fend.mitre.org/ontologies/d3fend/1.6.0/d3fend.json', 'd3fend-reference']
] as const) {
const bytes = await readFile(path);
const catalog = convert(JSON.parse(bytes.toString('utf8')), sourceUrl, createHash('sha256').update(bytes).digest('hex'));
const output = `src/assets/data/${name}-v${catalog.version}.json`;
await writeFile(output, JSON.stringify(catalog) + '\n');
console.log(`${output}: ${catalog.entries.length} entries`);
}

View File

@ -30,6 +30,8 @@ import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-s
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline'; import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service'; import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
import type { BackendArtifactStore } from './artifacts/backend-artifact-store'; import type { BackendArtifactStore } from './artifacts/backend-artifact-store';
import { MitreCatalogError, type MitreCatalogService } from './reference-data/mitre-catalog-service';
import { ReferenceRecognitionService, deriveReferenceRun } from './reference-data/reference-recognition-service';
import { directImportExecutor, type ImportExecutor } from './import-executor'; import { directImportExecutor, type ImportExecutor } from './import-executor';
import { createCanonicalImportBatch } from '../src/importers/canonical-import'; import { createCanonicalImportBatch } from '../src/importers/canonical-import';
import { processingScopeIssues } from '../src/domain/processing-compatibility'; import { processingScopeIssues } from '../src/domain/processing-compatibility';
@ -124,6 +126,7 @@ export interface AuditEvent {
export interface AssessorHttpServerOptions { export interface AssessorHttpServerOptions {
artifactStore?: BackendArtifactStore; artifactStore?: BackendArtifactStore;
mitreCatalogService?: MitreCatalogService;
repository: AssessorRepository; repository: AssessorRepository;
staticRoot: string; staticRoot: string;
releaseId?: string; releaseId?: string;
@ -343,6 +346,7 @@ function importFailureMessage(error: unknown): string {
} }
function statusForError(error: unknown): { status: number; code: string; message: string } { function statusForError(error: unknown): { status: number; code: string; message: string } {
if (error instanceof HttpError) return error; if (error instanceof HttpError) return error;
if (error instanceof MitreCatalogError) return { status: 502, code: error.code, message: error.message };
if (error instanceof ImportError) return { status: 422, code: error.diagnostics[0]?.code ?? 'import_rejected', message: error.message }; if (error instanceof ImportError) return { status: 422, code: error.diagnostics[0]?.code ?? 'import_rejected', message: error.message };
if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message }; if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message };
if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message }; if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message };
@ -376,6 +380,8 @@ async function serveStatic(request: IncomingMessage, response: ServerResponse, s
export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server { export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server {
const repository = options.repository; const repository = options.repository;
const referenceRecognition = new ReferenceRecognitionService(repository, options.mitreCatalogService
? () => options.mitreCatalogService!.recognitionCatalogs() : undefined);
const maximumBodyBytes = options.maximumBodyBytes ?? DEFAULT_MAXIMUM_BODY_BYTES; const maximumBodyBytes = options.maximumBodyBytes ?? DEFAULT_MAXIMUM_BODY_BYTES;
const now = options.now ?? (() => new Date()); const now = options.now ?? (() => new Date());
const importExecutor = options.importExecutor ?? directImportExecutor; const importExecutor = options.importExecutor ?? directImportExecutor;
@ -411,6 +417,35 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
}; };
status = 200; sendJson(response, status, result, requestId); return; status = 200; sendJson(response, status, result, requestId); return;
} }
if (segments.join('/') === 'reference-data/mitre-attack' && method === 'GET') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
status = 200; sendJson(response, status, await options.mitreCatalogService.current(), requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'references' && segments.length === 3 && method === 'GET') {
status = 200; sendJson(response, status, await referenceRecognition.status(segments[1]), requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'references' && segments[3] === 'recognize' && segments.length === 4 && method === 'POST') {
await readJson(request, maximumBodyBytes);
status = 200; sendJson(response, status, await referenceRecognition.reprocess(segments[1]), requestId); return;
}
if (segments.join('/') === 'reference-data/mitre-attack/refresh' && method === 'POST') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.mitreCatalogService.refresh(), requestId); return;
}
if (segments.join('/') === 'reference-data/mitre-attack/source' && method === 'GET') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
const source = await options.mitreCatalogService.officialSource();
status = 200;
response.statusCode = status;
response.setHeader('Content-Type', 'application/json; charset=utf-8');
response.setHeader('Content-Disposition', `attachment; filename="enterprise-attack-v${source.catalog.version}.json"`);
response.setHeader('Content-Length', source.bytes.byteLength);
response.setHeader('Cache-Control', 'no-store');
response.setHeader('X-Assessor-Request-Id', requestId);
response.end(source.bytes);
return;
}
if (method === 'GET' && segments.length === 1 && segments[0] === 'audit-events') { if (method === 'GET' && segments.length === 1 && segments[0] === 'audit-events') {
status = 200; sendJson(response, status, auditEvents.slice().reverse(), requestId); return; status = 200; sendJson(response, status, auditEvents.slice().reverse(), requestId); return;
} }
@ -605,7 +640,9 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
if (!body.replacesScanRunId && processingScopeIssues([...processingFiles, ...canonical.model.importFiles]).length) { if (!body.replacesScanRunId && processingScopeIssues([...processingFiles, ...canonical.model.importFiles]).length) {
throw new HttpError(409, 'incompatible_processing_versions', 'Der gewählte Timeslot enthält unbekannte oder semantisch inkompatible Verarbeitungsstände. Bitte alle betroffenen Dateien mit dem aktuellen Profil in einen neuen Timeslot reimportieren.'); throw new HttpError(409, 'incompatible_processing_versions', 'Der gewählte Timeslot enthält unbekannte oder semantisch inkompatible Verarbeitungsstände. Bitte alle betroffenen Dateien mit dem aktuellen Profil in einen neuen Timeslot reimportieren.');
} }
const committed = await repository.commitCanonicalImport({ ...canonical, replacesScanRunId: body.replacesScanRunId }); const referenceCatalogs = await referenceRecognition.catalogs();
canonical.model.referenceRuns = canonical.model.scanRuns.map(scan => deriveReferenceRun(canonical.model, scan.id, referenceCatalogs, importedAt));
const committed = await repository.commitCanonicalImport({ ...canonical, referenceCatalogs, replacesScanRunId: body.replacesScanRunId });
const fingerprints = await repository.findImportFingerprints(body.entries.map(entry => entry.sha256)); const fingerprints = await repository.findImportFingerprints(body.entries.map(entry => entry.sha256));
importBatchPreviews.delete(body.batchId); importBatchPreviews.delete(body.batchId);
const result: ImportBatchCompleteResponse = { const result: ImportBatchCompleteResponse = {

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { defaultImportResourcePolicy } from '../src/importers/resource-policy'; import { defaultImportResourcePolicy } from '../src/importers/resource-policy';
import { createAssessorHttpServer } from './assessor-http-server'; import { createAssessorHttpServer } from './assessor-http-server';
import { NodeImportWorkerExecutor } from './import-worker-client'; import { NodeImportWorkerExecutor } from './import-worker-client';
@ -17,6 +17,7 @@ import { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service'
import { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service'; import { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
import { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service'; import { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
import { loadPolicyEvaluationRuleCatalog } from './gpo/policy-evaluation-rule-catalog-loader'; import { loadPolicyEvaluationRuleCatalog } from './gpo/policy-evaluation-rule-catalog-loader';
import { MitreCatalogService } from './reference-data/mitre-catalog-service';
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number { function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
if (value === undefined || value.trim() === '') return fallback; if (value === undefined || value.trim() === '') return fallback;
@ -58,6 +59,7 @@ async function main(): Promise<void> {
}); });
const runtime = await createRepositoryRuntime(); const runtime = await createRepositoryRuntime();
const artifactStore = new FileSystemBackendArtifactStore(artifactRoot); const artifactStore = new FileSystemBackendArtifactStore(artifactRoot);
const mitreCatalogService = new MitreCatalogService(join(artifactRoot, 'reference-data', 'mitre-enterprise.json'));
const gpoBackupSubmissionService = new GpoBackupSubmissionService( const gpoBackupSubmissionService = new GpoBackupSubmissionService(
runtime.repository, runtime.repository,
artifactStore, artifactStore,
@ -101,6 +103,7 @@ async function main(): Promise<void> {
); );
const server = createAssessorHttpServer({ const server = createAssessorHttpServer({
artifactStore, artifactStore,
mitreCatalogService,
repository: runtime.repository, repository: runtime.repository,
persistence: runtime.persistence, persistence: runtime.persistence,
staticRoot, staticRoot,

View File

@ -18,6 +18,7 @@ import policyFindingFactory from './migrations/017_policy_finding_factory.sql?ra
import scannerEnvironmentContext from './migrations/018_scanner_environment_context.sql?raw'; import scannerEnvironmentContext from './migrations/018_scanner_environment_context.sql?raw';
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw'; import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw'; import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
import referenceRecognition from './migrations/023_reference_recognition.sql?raw';
export interface DatabaseMigration { export interface DatabaseMigration {
version: string; version: string;
@ -45,5 +46,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '017', name: 'policy-finding-factory', sql: policyFindingFactory }, { version: '017', name: 'policy-finding-factory', sql: policyFindingFactory },
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }, { version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext },
{ version: '019', name: 'native-policy-targets', sql: nativePolicyTargets }, { version: '019', name: 'native-policy-targets', sql: nativePolicyTargets },
{ version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation } { version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation },
{ version: '023', name: 'reference-recognition', sql: referenceRecognition }
]; ];

View File

@ -0,0 +1,21 @@
-- 021/022 are already reserved by timeslot comparison/obligation work in the canonical datastore.
-- Immutable public catalog snapshots; client runs reference their exact content hash.
CREATE TABLE assessor_reference_catalogs (
sha256 text PRIMARY KEY CHECK (sha256 ~ '^[a-f0-9]{64}$'),
data jsonb NOT NULL CHECK (jsonb_typeof(data) = 'object')
);
CREATE TABLE assessor_reference_runs (
id text PRIMARY KEY CHECK (btrim(id) <> ''),
project_id text NOT NULL REFERENCES assessor_projects(id) ON DELETE CASCADE,
assessment_id text NOT NULL REFERENCES assessor_assessments(id) ON DELETE CASCADE,
scan_run_id text NOT NULL REFERENCES assessor_scan_runs(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL,
data jsonb NOT NULL CHECK (jsonb_typeof(data) = 'object')
);
CREATE INDEX assessor_reference_runs_scan_idx ON assessor_reference_runs(scan_run_id, created_at DESC, id DESC);
CREATE INDEX assessor_reference_runs_project_idx ON assessor_reference_runs(project_id);
CREATE TABLE assessor_reference_run_catalogs (
run_id text NOT NULL REFERENCES assessor_reference_runs(id) ON DELETE CASCADE,
catalog_sha256 text NOT NULL REFERENCES assessor_reference_catalogs(sha256),
PRIMARY KEY (run_id, catalog_sha256)
);

View File

@ -26,6 +26,9 @@ import type { StoredPolicyFindingRun } from '../../src/domain/policy-finding';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../../src/domain/environment-context'; import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../../src/domain/environment-context';
import { environmentCoverage } from '../../src/domain/environment-context'; import { environmentCoverage } from '../../src/domain/environment-context';
import { storedPolicyFindingRunValidationErrors } from '../../src/domain/policy-finding'; import { storedPolicyFindingRunValidationErrors } from '../../src/domain/policy-finding';
import { latestReferenceRuns, type ReferenceRecognitionRun, type ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition';
import { validateReferenceRun } from '../../src/persistence/reference-run-validation';
import { referenceHash } from '../reference-data/reference-catalogs';
import { import {
environmentProfileValidationErrors, environmentProfileValidationErrors,
storedPolicySnapshotAggregationValidationErrors storedPolicySnapshotAggregationValidationErrors
@ -499,7 +502,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
}); });
} }
private async assessmentModel(queryable: Queryable, assessmentId: string): Promise<CanonicalAssessmentModel | null> { private async assessmentModel(queryable: Queryable, assessmentId: string, includeSuperseded = false): Promise<CanonicalAssessmentModel | null> {
const assessment = dataRow(await queryable.query<DataRow<Assessment>>( const assessment = dataRow(await queryable.query<DataRow<Assessment>>(
'SELECT data FROM assessor_assessments WHERE id = $1', [assessmentId] 'SELECT data FROM assessor_assessments WHERE id = $1', [assessmentId]
)); ));
@ -510,49 +513,52 @@ export class PostgresAssessorRepository implements AssessorRepository {
if (!project) return null; if (!project) return null;
const runs = await queryable.query<DataRow<ScanRun>>( const runs = await queryable.query<DataRow<ScanRun>>(
`SELECT data FROM assessor_scan_runs `SELECT data FROM assessor_scan_runs
WHERE assessment_id = $1 AND data->>'supersededByScanRunId' IS NULL ORDER BY id`, [assessmentId] WHERE assessment_id = $1 AND ($2::boolean OR data->>'supersededByScanRunId' IS NULL) ORDER BY id`, [assessmentId, includeSuperseded]
); );
const files = await queryable.query<DataRow<ImportFileRecord>>( const files = await queryable.query<DataRow<ImportFileRecord>>(
`SELECT f.data FROM assessor_import_files f JOIN assessor_scan_runs r ON r.id = f.scan_run_id `SELECT f.data FROM assessor_import_files f JOIN assessor_scan_runs r ON r.id = f.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY f.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY f.id`, [assessmentId, includeSuperseded]
); );
const tables = await queryable.query<DataRow<SourceTableRecord>>( const tables = await queryable.query<DataRow<SourceTableRecord>>(
`SELECT t.data FROM assessor_source_tables t JOIN assessor_import_files f ON f.id = t.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id `SELECT t.data FROM assessor_source_tables t JOIN assessor_import_files f ON f.id = t.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY t.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY t.id`, [assessmentId, includeSuperseded]
); );
const reportSections = await queryable.query<DataRow<ScannerReportSection>>( const reportSections = await queryable.query<DataRow<ScannerReportSection>>(
`SELECT s.data FROM assessor_scanner_report_sections s JOIN assessor_scan_runs r ON r.id = s.scan_run_id `SELECT s.data FROM assessor_scanner_report_sections s JOIN assessor_scan_runs r ON r.id = s.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY s.ordinal, s.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY s.ordinal, s.id`, [assessmentId, includeSuperseded]
); );
const environmentObservations = await queryable.query<DataRow<EnvironmentObservation>>( const environmentObservations = await queryable.query<DataRow<EnvironmentObservation>>(
`SELECT o.data FROM assessor_environment_observations o JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT o.data FROM assessor_environment_observations o JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY o.property_key, o.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY o.property_key, o.id`, [assessmentId, includeSuperseded]
); );
const definitions = await queryable.query<DataRow<FindingDefinition>>( const definitions = await queryable.query<DataRow<FindingDefinition>>(
`SELECT DISTINCT d.data, d.id FROM assessor_finding_definitions d JOIN assessor_finding_occurrences o ON o.definition_id = d.id JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT DISTINCT d.data, d.id FROM assessor_finding_definitions d JOIN assessor_finding_occurrences o ON o.definition_id = d.id JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY d.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY d.id`, [assessmentId, includeSuperseded]
); );
const occurrences = await queryable.query<DataRow<FindingOccurrence>>( const occurrences = await queryable.query<DataRow<FindingOccurrence>>(
`SELECT o.data FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT o.data FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY o.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY o.id`, [assessmentId, includeSuperseded]
); );
const evidence = await queryable.query<DataRow<EvidenceRecord>>( const evidence = await queryable.query<DataRow<EvidenceRecord>>(
`SELECT e.data FROM assessor_evidence e JOIN assessor_finding_occurrences o ON o.id = e.occurrence_id JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT e.data FROM assessor_evidence e JOIN assessor_finding_occurrences o ON o.id = e.occurrence_id JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY e.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY e.id`, [assessmentId, includeSuperseded]
); );
const reviews = await queryable.query<DataRow<FindingReview>>(` const reviews = await queryable.query<DataRow<FindingReview>>(`
SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id SELECT 1 FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL AND (rv.occurrence_id = o.id OR rv.definition_id = o.definition_id) WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) AND (rv.occurrence_id = o.id OR rv.definition_id = o.definition_id)
) ORDER BY rv.id ) ORDER BY rv.id
`, [assessmentId]); `, [assessmentId, includeSuperseded]);
const diagnostics = await queryable.query<DataRow<ImportDiagnostic>>( const diagnostics = await queryable.query<DataRow<ImportDiagnostic>>(
`SELECT d.data FROM assessor_import_diagnostics d JOIN assessor_import_files f ON f.id = d.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id `SELECT d.data FROM assessor_import_diagnostics d JOIN assessor_import_files f ON f.id = d.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY d.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY d.id`, [assessmentId, includeSuperseded]
); );
return { return {
schemaVersion: 1, project, assessment, schemaVersion: 1, project, assessment,
referenceRuns: latestReferenceRuns(dataRows(await queryable.query<DataRow<ReferenceRecognitionRun>>(
'SELECT DISTINCT ON (scan_run_id) data FROM assessor_reference_runs WHERE assessment_id = $1 ORDER BY scan_run_id, created_at DESC, id DESC', [assessmentId]
))).filter(run => dataRows(runs).some(scan => scan.id === run.scanRunId)),
scanRuns: dataRows(runs), importFiles: dataRows(files), sourceTables: dataRows(tables), scanRuns: dataRows(runs), importFiles: dataRows(files), sourceTables: dataRows(tables),
reportSections: dataRows(reportSections), environmentObservations: dataRows(environmentObservations), reportSections: dataRows(reportSections), environmentObservations: dataRows(environmentObservations),
findingDefinitions: dataRows(definitions), findingOccurrences: dataRows(occurrences), findingDefinitions: dataRows(definitions), findingOccurrences: dataRows(occurrences),
@ -564,6 +570,40 @@ export class PostgresAssessorRepository implements AssessorRepository {
return this.assessmentModel(this.pool, assessmentId); return this.assessmentModel(this.pool, assessmentId);
} }
private async persistReferenceRun(client: PoolClient, run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[], model: CanonicalAssessmentModel | null): Promise<boolean> {
const existing = dataRow(await client.query<DataRow<ReferenceRecognitionRun>>('SELECT data FROM assessor_reference_runs WHERE id = $1', [run.id]));
if (existing) {
if (canonicalJson(existing) !== canonicalJson(run)) throw new RepositoryConflictError('Referenzläufe sind unveränderlich.');
return false;
}
validateReferenceRun(run, catalogs, model);
for (const catalog of catalogs) {
const { sha256, ...content } = catalog;
if (referenceHash(content) !== sha256) throw new RepositoryValidationError('Katalog-Hash stimmt nicht überein.');
const stored = dataRow(await client.query<DataRow<ReferenceCatalogSnapshot>>('SELECT data FROM assessor_reference_catalogs WHERE sha256 = $1', [sha256]));
if (stored && canonicalJson(stored) !== canonicalJson(catalog)) throw new RepositoryConflictError('Katalog-Snapshot ist unveränderlich.');
if (!stored) await client.query('INSERT INTO assessor_reference_catalogs (sha256, data) VALUES ($1, $2::jsonb)', [sha256, json(catalog)]);
}
await client.query('INSERT INTO assessor_reference_runs (id, project_id, assessment_id, scan_run_id, created_at, data) VALUES ($1, $2, $3, $4, $5, $6::jsonb)',
[run.id, run.projectId, run.assessmentId, run.scanRunId, run.createdAt, json(run)]);
for (const hash of run.catalogHashes) await client.query('INSERT INTO assessor_reference_run_catalogs (run_id, catalog_sha256) VALUES ($1, $2)', [run.id, hash]);
return true;
}
async saveReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): Promise<void> {
await this.mutate(async client => this.persistReferenceRun(client, run, catalogs, await this.assessmentModel(client, run.assessmentId)), undefined, Boolean);
}
async listReferenceRuns(projectId: string): Promise<ReferenceRecognitionRun[]> {
return dataRows(await this.pool.query<DataRow<ReferenceRecognitionRun>>('SELECT data FROM assessor_reference_runs WHERE project_id = $1 ORDER BY created_at, id', [projectId]));
}
async listReferenceCatalogs(projectId: string): Promise<ReferenceCatalogSnapshot[]> {
return dataRows(await this.pool.query<DataRow<ReferenceCatalogSnapshot>>(`SELECT c.data FROM assessor_reference_catalogs c
WHERE EXISTS (SELECT 1 FROM assessor_reference_run_catalogs rc JOIN assessor_reference_runs r ON r.id = rc.run_id
WHERE r.project_id = $1 AND rc.catalog_sha256 = c.sha256) ORDER BY c.sha256`, [projectId]));
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> { async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.'); if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`); if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -836,6 +876,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
fingerprint.firstImportedAt, fingerprint.lastImportedAt, fingerprint.importCount, json(fingerprint)]); fingerprint.firstImportedAt, fingerprint.lastImportedAt, fingerprint.importCount, json(fingerprint)]);
} }
const stored = await this.assessmentModel(client, model.assessment.id); const stored = await this.assessmentModel(client, model.assessment.id);
for (const referenceRun of model.referenceRuns ?? []) await this.persistReferenceRun(client, referenceRun, input.referenceCatalogs ?? [], stored);
if (!stored) throw new RepositoryValidationError('Das importierte Assessment konnte nicht aufgebaut werden.'); if (!stored) throw new RepositoryValidationError('Das importierte Assessment konnte nicht aufgebaut werden.');
try { assertCanonicalAssessmentModel(stored); } try { assertCanonicalAssessmentModel(stored); }
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Importmodell ist ungültig.'); } catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Importmodell ist ungültig.'); }
@ -2394,7 +2435,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
const project = await this.getProject(projectId); const project = await this.getProject(projectId);
if (!project) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`); if (!project) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
const assessments = await this.listAssessments(projectId); const assessments = await this.listAssessments(projectId);
const models = (await Promise.all(assessments.map(assessment => this.loadAssessmentModel(assessment.id)))) const models = (await Promise.all(assessments.map(assessment => this.assessmentModel(this.pool, assessment.id, true))))
.filter((model): model is CanonicalAssessmentModel => model !== null); .filter((model): model is CanonicalAssessmentModel => model !== null);
const policyFindingRuns = await this.listPolicyFindingRuns(projectId); const policyFindingRuns = await this.listPolicyFindingRuns(projectId);
const uniqueDefinitions = new Map([ const uniqueDefinitions = new Map([
@ -2403,6 +2444,8 @@ export class PostgresAssessorRepository implements AssessorRepository {
].map(item => [item.id, item])); ].map(item => [item.id, item]));
return { return {
version: 1, project, assessments, version: 1, project, assessments,
referenceRuns: await this.listReferenceRuns(projectId),
referenceCatalogs: await this.listReferenceCatalogs(projectId),
scanRuns: models.flatMap(model => model.scanRuns), scanRuns: models.flatMap(model => model.scanRuns),
importFiles: models.flatMap(model => model.importFiles), importFiles: models.flatMap(model => model.importFiles),
sourceTables: models.flatMap(model => model.sourceTables), sourceTables: models.flatMap(model => model.sourceTables),

View File

@ -0,0 +1,145 @@
import { createHash, randomUUID } from 'node:crypto';
import { attackReferenceCatalog } from '../../src/references/catalog-converters';
import type { ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition';
import { bundledReferenceCatalogs, referenceHash, referenceSnapshot } from './reference-catalogs';
import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute } from 'node:path';
import { defaultMitreGraphData, validateMitreMatrixData, type MitreGraphData } from '../../src/features/mitre-graph/mitre-graph';
import { convertEnterpriseStix } from '../../src/features/mitre-graph/mitre-stix';
const OFFICIAL_ENTERPRISE_STIX_URL = 'https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json';
const MAXIMUM_SOURCE_BYTES = 80 * 1024 * 1024;
const MAXIMUM_CATALOG_BYTES = 5 * 1024 * 1024;
export class MitreCatalogError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = 'MitreCatalogError';
}
}
export interface MitreCatalogState {
referenceCatalog?: ReferenceCatalogSnapshot;
version: 1;
catalog: MitreGraphData;
origin: 'bundled' | 'mitre';
}
function compareVersions(left: string, right: string): number {
const a = left.split('.').map(Number);
const b = right.split('.').map(Number);
return (a[0] - b[0]) || (a[1] - b[1]);
}
export class MitreCatalogService {
private refreshInProgress?: Promise<MitreCatalogState & { updated: boolean }>;
constructor(
private readonly catalogPath: string,
private readonly fetcher: typeof fetch = globalThis.fetch.bind(globalThis)
) {
if (!catalogPath || !isAbsolute(catalogPath)) throw new Error('An absolute MITRE catalog storage path is required.');
}
async current(): Promise<MitreCatalogState> {
try {
const information = await stat(this.catalogPath);
if (!information.isFile() || information.size > MAXIMUM_CATALOG_BYTES) throw new Error('Stored MITRE catalog is not a regular bounded file.');
const stored = JSON.parse(await readFile(this.catalogPath, 'utf8'));
const catalog = validateMitreMatrixData(stored);
const referenceCatalog = stored.referenceCatalog as ReferenceCatalogSnapshot | undefined;
if (referenceCatalog) {
const { sha256, ...content } = referenceCatalog;
if (content.framework !== 'mitre-attack' || content.version !== catalog.version || referenceHash(content) !== sha256) throw new Error('Stored reference catalog is invalid.');
}
return compareVersions(catalog.version, defaultMitreGraphData.version) >= 0
? { version: 1, catalog, origin: 'mitre', ...(referenceCatalog ? { referenceCatalog } : {}) }
: { version: 1, catalog: defaultMitreGraphData, origin: 'bundled' };
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return { version: 1, catalog: defaultMitreGraphData, origin: 'bundled' };
}
throw error;
}
}
async recognitionCatalogs(): Promise<ReferenceCatalogSnapshot[]> {
const state = await this.current();
const attack = state.referenceCatalog ?? bundledReferenceCatalogs[0];
if (attack.version !== state.catalog.version) throw new MitreCatalogError('mitre_reference_update_required', 'Bitte zuerst Update from MITRE ausführen, um den vollständigen Referenzkatalog zu laden.');
return [attack, bundledReferenceCatalogs[1]];
}
async officialSource(): Promise<{ bytes: Buffer; catalog: MitreGraphData; referenceCatalog: ReferenceCatalogSnapshot }> {
let response: Response;
try {
response = await this.fetcher(OFFICIAL_ENTERPRISE_STIX_URL, {
headers: { Accept: 'application/json' }, cache: 'no-store', redirect: 'error',
signal: AbortSignal.timeout(120_000)
});
} catch {
throw new MitreCatalogError('mitre_unreachable', 'MITRE konnte nicht erreicht werden. Bitte später erneut versuchen oder die Datei manuell importieren.');
}
if (!response.ok || !response.body) throw new MitreCatalogError('mitre_unavailable', `MITRE lieferte keine nutzbare Datei (HTTP ${response.status}).`);
const declaredSize = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredSize) && declaredSize > MAXIMUM_SOURCE_BYTES) {
await response.body.cancel();
throw new MitreCatalogError('mitre_source_too_large', 'Die MITRE-Datei überschreitet das Größenlimit.');
}
const chunks: Buffer[] = [];
const reader = response.body.getReader();
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAXIMUM_SOURCE_BYTES) throw new MitreCatalogError('mitre_source_too_large', 'Die MITRE-Datei überschreitet das Größenlimit.');
chunks.push(Buffer.from(value));
}
} catch (error) {
await reader.cancel().catch(() => undefined);
if (error instanceof MitreCatalogError) throw error;
throw new MitreCatalogError('mitre_download_failed', 'Der MITRE-Download wurde unterbrochen.');
}
const bytes = Buffer.concat(chunks, size);
try {
const raw = JSON.parse(bytes.toString('utf8'));
return { bytes, catalog: validateMitreMatrixData(convertEnterpriseStix(raw)),
referenceCatalog: referenceSnapshot(attackReferenceCatalog(raw, OFFICIAL_ENTERPRISE_STIX_URL, createHash('sha256').update(bytes).digest('hex'))) };
} catch {
throw new MitreCatalogError('mitre_source_invalid', 'Die MITRE-Datei enthält keinen gültigen Enterprise-ATT&CK-STIX-Katalog.');
}
}
refresh(): Promise<MitreCatalogState & { updated: boolean }> {
if (this.refreshInProgress) return this.refreshInProgress;
this.refreshInProgress = this.performRefresh().finally(() => { this.refreshInProgress = undefined; });
return this.refreshInProgress;
}
private async performRefresh(): Promise<MitreCatalogState & { updated: boolean }> {
const latest = await this.officialSource();
const current = await this.current();
const comparison = compareVersions(latest.catalog.version, current.catalog.version);
if (comparison < 0 || (comparison === 0 && current.referenceCatalog)) {
return { ...current, updated: false };
}
const directory = dirname(this.catalogPath);
await mkdir(directory, { recursive: true, mode: 0o700 });
const directoryInfo = await lstat(directory);
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
throw new MitreCatalogError('mitre_storage_unavailable', 'Der private MITRE-Speicher ist nicht verfügbar.');
}
const temporaryPath = `${this.catalogPath}.${randomUUID()}.tmp`;
try {
await writeFile(temporaryPath, JSON.stringify({ ...latest.catalog, referenceCatalog: latest.referenceCatalog }), { mode: 0o600 });
await rename(temporaryPath, this.catalogPath);
} finally {
await unlink(temporaryPath).catch(error => {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
});
}
return { version: 1, catalog: latest.catalog, referenceCatalog: latest.referenceCatalog, origin: 'mitre', updated: comparison > 0 };
}
}

View File

@ -0,0 +1,11 @@
import { createHash } from 'node:crypto';
import attack from '../../src/assets/data/attack-reference-v19.2.json';
import d3fend from '../../src/assets/data/d3fend-reference-v1.6.0.json';
import type { ReferenceCatalog, ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition';
import { canonicalJson } from '../../src/shared/canonical-json';
export const referenceHash = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex');
export function referenceSnapshot(catalog: ReferenceCatalog): ReferenceCatalogSnapshot {
return { ...catalog, sha256: referenceHash(catalog) };
}
export const bundledReferenceCatalogs = [attack, d3fend].map(catalog => referenceSnapshot(catalog as ReferenceCatalog));

View File

@ -0,0 +1,66 @@
import type { CanonicalAssessmentModel } from '../../src/domain/canonical-model';
import { createReferenceRecognizer, latestReferenceRuns, REFERENCE_RECOGNIZER_VERSION, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../../src/domain/reference-recognition';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError } from '../../src/persistence/repository-errors';
import { bundledReferenceCatalogs, referenceHash } from './reference-catalogs';
export function deriveReferenceRun(model: CanonicalAssessmentModel, scanRunId: string, catalogs: ReferenceCatalogSnapshot[], createdAt: string): ReferenceRecognitionRun {
const occurrences = model.findingOccurrences.filter(item => item.scanRunId === scanRunId).sort((a, b) => a.id.localeCompare(b.id));
const files = new Map(model.importFiles.map(file => [file.id, file]));
const inputSha256 = referenceHash(occurrences.map(item => ({ id: item.id, importFileId: item.importFileId,
sourceFileSha256: files.get(item.importFileId)?.sha256, sourcePosition: item.sourcePosition, fields: item.originalFields })));
const catalogHashes = catalogs.map(catalog => catalog.sha256).sort();
const id = 'references-' + referenceHash([model.project.id, model.assessment.id, scanRunId, REFERENCE_RECOGNIZER_VERSION, inputSha256, catalogHashes]);
const recognize = createReferenceRecognizer(catalogs);
return { schemaVersion: 1, id, projectId: model.project.id, assessmentId: model.assessment.id, scanRunId,
recognizerVersion: REFERENCE_RECOGNIZER_VERSION, inputSha256, catalogHashes, createdAt,
results: occurrences.map(item => ({ occurrenceId: item.id, importFileId: item.importFileId,
sourceFileSha256: files.get(item.importFileId)?.sha256, sourcePosition: item.sourcePosition, references: recognize(item.originalFields) })) };
}
export class ReferenceRecognitionService {
private active = new Map<string, Promise<{ processedScans: number; unchangedScans: number; references: number }>>();
constructor(private readonly repository: AssessorRepository,
readonly catalogs: () => Promise<ReferenceCatalogSnapshot[]> = async () => bundledReferenceCatalogs) {}
async status(projectId: string) {
if (!await this.repository.getProject(projectId)) throw new RepositoryNotFoundError('Client nicht vorhanden.');
const scans = (await this.repository.listProjectScanRuns(projectId)).filter(scan => !scan.supersededByScanRunId);
const runs = latestReferenceRuns(await this.repository.listReferenceRuns(projectId));
const catalogs = await this.catalogs();
const hashes = catalogs.map(catalog => catalog.sha256).sort().join(':');
const indexed = scans.filter(scan => runs.some(run => run.scanRunId === scan.id));
return { totalScans: scans.length, indexedScans: indexed.length,
pendingScans: scans.filter(scan => !runs.some(run => run.scanRunId === scan.id && run.recognizerVersion === REFERENCE_RECOGNIZER_VERSION && [...run.catalogHashes].sort().join(':') === hashes)).length,
catalogs: catalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) };
}
reprocess(projectId: string) {
let task = this.active.get(projectId);
if (!task) {
task = this.performReprocess(projectId).finally(() => this.active.delete(projectId));
this.active.set(projectId, task);
}
return task;
}
private async performReprocess(projectId: string) {
if (!await this.repository.getProject(projectId)) throw new RepositoryNotFoundError('Client nicht vorhanden.');
const catalogs = await this.catalogs();
const existing = new Map((await this.repository.listReferenceRuns(projectId)).map(run => [run.id, run]));
let processedScans = 0, unchangedScans = 0, references = 0;
for (const assessment of await this.repository.listAssessments(projectId)) {
const model = await this.repository.loadAssessmentModel(assessment.id);
if (!model) continue;
for (const scan of model.scanRuns.filter(item => !item.supersededByScanRunId)) {
const latestTimestamp = Math.max(0, ...[...existing.values()].filter(run => run.scanRunId === scan.id).map(run => Date.parse(run.createdAt)));
const run = deriveReferenceRun(model, scan.id, catalogs, new Date(Math.max(Date.now(), latestTimestamp + 1)).toISOString());
references += run.results.reduce((sum, item) => sum + item.references.length, 0);
if (existing.has(run.id)) { unchangedScans++; continue; }
await this.repository.saveReferenceRun(run, catalogs);
processedScans++;
}
}
return { processedScans, unchangedScans, references };
}
}

View File

@ -19,6 +19,7 @@ import {
ASSESSOR_MUTATION_HEADER, ASSESSOR_MUTATION_HEADER,
type AssessorApiErrorBody, type AssessorApiErrorBody,
type AssessorHealthResponse, type AssessorHealthResponse,
type MitreCatalogResponse,
type GpoBackupSubmissionResponse, type GpoBackupSubmissionResponse,
type GpoBackupStatusResponse, type GpoBackupStatusResponse,
type GpoBackupRetryResponse, type GpoBackupRetryResponse,
@ -130,6 +131,36 @@ export class AssessorApiClient {
return this.request('/health'); return this.request('/health');
} }
getReferenceRecognitionStatus(projectId: string): Promise<import('./assessor-api-contract').ReferenceRecognitionStatus> {
return this.request(`/projects/${encodeURIComponent(projectId)}/references`);
}
recognizeReferences(projectId: string): Promise<import('./assessor-api-contract').ReferenceRecognitionResponse> {
return this.request(`/projects/${encodeURIComponent(projectId)}/references/recognize`, { method: 'POST', body: '{}' });
}
getMitreCatalog(): Promise<MitreCatalogResponse> {
return this.request('/reference-data/mitre-attack');
}
refreshMitreCatalog(): Promise<MitreCatalogResponse & { updated: boolean }> {
return this.request('/reference-data/mitre-attack/refresh', { method: 'POST', body: '{}' });
}
async downloadLatestMitreSource(): Promise<{ blob: Blob; fileName: string }> {
const response = await this.fetcher(`${this.baseUrl}/reference-data/mitre-attack/source`, {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
const body = await response.json().catch(() => null) as AssessorApiErrorBody | null;
throw new AssessorApiError(body?.error?.message ?? `MITRE-Download fehlgeschlagen (${response.status}).`,
response.status, body?.error?.code ?? 'mitre_download_failed', body?.error?.requestId);
}
const fileName = response.headers.get('content-disposition')?.match(/filename="(enterprise-attack-v\d+\.\d+\.json)"/)?.[1]
?? 'enterprise-attack.json';
return { blob: await response.blob(), fileName };
}
getDatabaseStructure(): Promise<DbStructureResponse> { getDatabaseStructure(): Promise<DbStructureResponse> {
return this.request('/db-structure'); return this.request('/db-structure');
} }

View File

@ -15,9 +15,17 @@ import type { CreatePolicyEvaluationRequest, StoredPolicyEvaluation } from '../d
import type { CreatePolicyFindingRunRequest, StoredPolicyFindingRun } from '../domain/policy-finding'; import type { CreatePolicyFindingRunRequest, StoredPolicyFindingRun } from '../domain/policy-finding';
import type { PolicyEvaluationRuleCatalog } from '../domain/policy-evaluation-rules'; import type { PolicyEvaluationRuleCatalog } from '../domain/policy-evaluation-rules';
import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview'; import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview';
import type { MitreGraphData } from '../features/mitre-graph/mitre-graph';
export const ASSESSOR_API_PREFIX = '/api/v1'; export const ASSESSOR_API_PREFIX = '/api/v1';
export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request'; export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request';
export interface MitreCatalogResponse {
version: 1;
catalog: MitreGraphData;
origin: 'bundled' | 'mitre';
updated?: boolean;
}
export interface AssessorHealthResponse { export interface AssessorHealthResponse {
status: 'ok'; status: 'ok';
application: 'ASSESSOR'; application: 'ASSESSOR';
@ -198,3 +206,14 @@ export interface PolicySnapshotAggregationListResponse { version: 1; projectId:
export type MicrosoftSecurityBaselineSelectionResponse = MicrosoftSecurityBaselineSelection; export type MicrosoftSecurityBaselineSelectionResponse = MicrosoftSecurityBaselineSelection;
export type ImportBatchPreviewResponse = ImportBatchPreview; export type ImportBatchPreviewResponse = ImportBatchPreview;
export interface ReferenceRecognitionStatus {
totalScans: number;
indexedScans: number;
pendingScans: number;
catalogs: Array<{ framework: import('../domain/reference-recognition').ReferenceFramework; version: string; sha256: string }>;
}
export interface ReferenceRecognitionResponse {
processedScans: number;
unchangedScans: number;
references: number;
}

View File

@ -15,6 +15,7 @@ import { genericProfile } from '../profiles/generic/generic-profile';
import { importProfiles } from '../profiles/profile-catalog'; import { importProfiles } from '../profiles/profile-catalog';
import { stableProjectId } from '../shared/identifiers'; import { stableProjectId } from '../shared/identifiers';
import { combineDatasets } from '../features/project-import/project-import'; import { combineDatasets } from '../features/project-import/project-import';
import { setServerMitreMatrix } from '../features/mitre-graph/mitre-graph';
import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace'; import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace';
import { profileForDataset, profileForSheet } from '../features/project-import/workspace-profile'; import { profileForDataset, profileForSheet } from '../features/project-import/workspace-profile';
export { profileForSheet } from '../features/project-import/workspace-profile'; export { profileForSheet } from '../features/project-import/workspace-profile';
@ -107,6 +108,13 @@ export function AppProvider({ children }: PropsWithChildren) {
return { ...initialAppState, theme: stored }; return { ...initialAppState, theme: stored };
}); });
useEffect(() => {
let active = true;
void assessorApi.getMitreCatalog().then(state => {
if (active) setServerMitreMatrix(state.catalog);
}).catch(() => undefined);
return () => { active = false; };
}, []);
useEffect(() => { useEffect(() => {
if (!workScopeId) return; if (!workScopeId) return;
const persist = () => { void reviewRepository.save(workScopeId, state.reviews); }; const persist = () => { void reviewRepository.save(workScopeId, state.reviews); };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@ import { resultStatusFieldFor } from '../../features/linked-results/result-messa
import { highlightText } from '../HighlightText/highlight-text'; import { highlightText } from '../HighlightText/highlight-text';
import { BookmarkIcon } from '../BookmarkIcon'; import { BookmarkIcon } from '../BookmarkIcon';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { policyFindingCategories } from '../../profiles/group-policy/categories'; import { policyFindingCategories } from '../../profiles/group-policy/categories';
import { measureNavigationStep } from '../../app/navigation-diagnostics'; import { measureNavigationStep } from '../../app/navigation-diagnostics';
@ -14,6 +15,8 @@ export interface TileBadge {
value: string; value: string;
text: string; text: string;
severity?: string; severity?: string;
/** Hover text, for example the report title of a CIS section. */
title?: string;
} }
export function tileBadges(record: FindingRecord, fallbackToolId?: string): TileBadge[] { export function tileBadges(record: FindingRecord, fallbackToolId?: string): TileBadge[] {
@ -33,7 +36,11 @@ export function tileBadges(record: FindingRecord, fallbackToolId?: string): Tile
const text = /^(Weight|Score)$/.test(label) ? `${label}: ${field.value}` : field.value; const text = /^(Weight|Score)$/.test(label) ? `${label}: ${field.value}` : field.value;
return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }]; return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }];
}); });
const cisBadges = cisBenchmarkCategories(record, fallbackToolId).map(value => ({ label: 'CIS Category', value, text: value })); // Tiles stay compact: the CIS number is the badge, the section title from the run's report is its hover text.
const cisBadges = cisBenchmarkCategories(record, fallbackToolId).map(value => {
const title = cisCategoryRecordTitle(record, value);
return { label: 'CIS Category', value, text: value, ...(title ? { title } : {}) };
});
const gpoBadges = policyFindingCategories(record, fallbackToolId).map(value => ({ label: 'GPO Category', value, text: value })); const gpoBadges = policyFindingCategories(record, fallbackToolId).map(value => ({ label: 'GPO Category', value, text: value }));
const scopeBadges = toolIdForRecord(record, fallbackToolId) === 'group-policy' const scopeBadges = toolIdForRecord(record, fallbackToolId) === 'group-policy'
? record.fields.filter(field => field.label.trim().toLowerCase() === 'scope' && field.value.trim()) ? record.fields.filter(field => field.label.trim().toLowerCase() === 'scope' && field.value.trim())
@ -110,7 +117,7 @@ function TileCardComponent({ record, title, bookmarked, resultColor, toolId, sea
plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed
und Investigate in der Kachelansicht identisch aus. */ und Investigate in der Kachelansicht identisch aus. */
? <span className="outcome-mark" data-badge-role="status" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span> ? <span className="outcome-mark" data-badge-role="status" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>
: <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' || badge.label === 'GPO Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)} : <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' || badge.label === 'GPO Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} title={badge.title} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)}
</div> </div>
</div> </div>
<div className="data-tile-actions"> <div className="data-tile-actions">

View File

@ -335,6 +335,8 @@ export interface ImportDiagnostic {
} }
export interface CanonicalAssessmentModel { export interface CanonicalAssessmentModel {
/** Latest stored reference derivation per scan; canonical source facts remain unchanged. */
referenceRuns?: import('./reference-recognition').ReferenceRecognitionRun[];
schemaVersion: 1; schemaVersion: 1;
project: AssessmentProject; project: AssessmentProject;
assessment: Assessment; assessment: Assessment;
@ -352,4 +354,3 @@ export interface CanonicalAssessmentModel {
diagnostics: ImportDiagnostic[]; diagnostics: ImportDiagnostic[];
} }

View File

@ -83,6 +83,8 @@ export interface DatasetSheet {
export interface ImportedDataset { export interface ImportedDataset {
/** Run-bound scoring evidence, prepared once while projecting the canonical workspace. */ /** Run-bound scoring evidence, prepared once while projecting the canonical workspace. */
securityScoring?: SecurityScoreContext; securityScoring?: SecurityScoreContext;
/** Benchmark section titles per scan run and section number, read from retained report tables. */
benchmarkSectionTitles?: Map<string, Map<string, string>>;
fileName: string; fileName: string;
fileSize: number; fileSize: number;
lastModified: number; lastModified: number;

View File

@ -58,6 +58,7 @@ export interface RecordField {
} }
export interface FindingRecord { export interface FindingRecord {
referenceRecognition?: import('./reference-recognition').FindingReferenceRecognition;
id: string; id: string;
/** Stable database identity of the projected canonical finding occurrence. */ /** Stable database identity of the projected canonical finding occurrence. */
findingOccurrenceId?: string; findingOccurrenceId?: string;
@ -66,6 +67,8 @@ export interface FindingRecord {
/** Exact source scope for native report metrics; never inferred from display names. */ /** Exact source scope for native report metrics; never inferred from display names. */
scanRunId?: string; scanRunId?: string;
importFileId?: string; importFileId?: string;
/** Benchmark section titles of this record's own scan run, by section number (presentation only). */
benchmarkSectionTitles?: ReadonlyMap<string, string>;
/** Presentation-only provenance of Fail comparisons, using the Processing tile's exact backup identity. */ /** Presentation-only provenance of Fail comparisons, using the Processing tile's exact backup identity. */
policyFailingBackupSources?: Array<{ id: string; label: string }>; policyFailingBackupSources?: Array<{ id: string; label: string }>;
policySourceEvidence?: PolicySourceEvidence[]; policySourceEvidence?: PolicySourceEvidence[];

View File

@ -0,0 +1,163 @@
import type { JsonValue, OriginalField, SourcePosition } from './canonical-model';
/** Independent derivation: these references never become FindingDefinition identity keys. */
export const REFERENCE_RECOGNIZER_VERSION = '1.0.0';
export type ReferenceFramework = 'mitre-attack' | 'mitre-d3fend';
export interface ReferenceCatalogEntry {
id: string;
title: string;
kind: 'technique' | 'tactic';
state: 'active' | 'deprecated' | 'revoked';
url: string;
tactics: string[];
}
export interface ReferenceCatalog {
schemaVersion: 1;
framework: ReferenceFramework;
version: string;
sourceUrl: string;
sourceSha256: string;
license: string;
entries: ReferenceCatalogEntry[];
}
export interface ReferenceCatalogSnapshot extends ReferenceCatalog {
/** SHA-256 of canonicalJson(catalog), including every recognition input and the notice. */
sha256: string;
}
export interface RecognizedReference {
framework: ReferenceFramework;
id: string;
title?: string;
kind?: 'technique' | 'tactic';
url?: string;
tactics: string[];
status: 'active' | 'deprecated' | 'revoked' | 'not-in-catalog';
evidence: 'declared' | 'mentioned';
matchedBy: 'id' | 'exact-title';
catalogVersion: string;
catalogSha256: string;
source: {
fieldKey: string;
fieldLabel: string;
fieldPath?: string;
/** JSON pointer within value; rawValue is authoritative when present. */
valuePath: string;
representation: 'rawValue' | 'value';
start: number;
end: number;
offsetUnit: 'utf16';
text: string;
};
}
export interface ReferenceOccurrenceResult {
occurrenceId: string;
importFileId: string;
sourceFileSha256?: string;
sourcePosition: SourcePosition;
references: RecognizedReference[];
}
export interface ReferenceRecognitionRun {
schemaVersion: 1;
id: string;
projectId: string;
assessmentId: string;
scanRunId: string;
recognizerVersion: string;
inputSha256: string;
catalogHashes: string[];
createdAt: string;
results: ReferenceOccurrenceResult[];
}
export interface FindingReferenceRecognition {
runId: string;
recognizerVersion: string;
createdAt: string;
references: RecognizedReference[];
}
/** Header semantics are framework-specific, never scanner-specific. */
export function declaredReferenceFramework(label: string): ReferenceFramework | undefined {
const value = label.trim().toLowerCase().replace(/[_-]+/g, ' ');
if (/\b(?:d3fend|mitre\s*(?:d3fend|defen[cs]e))\b/.test(value)) return 'mitre-d3fend';
if (/\b(?:att&?ck|mitre\s*(?:att&?ck|attack))\b/.test(value)) return 'mitre-attack';
return undefined;
}
function* strings(value: JsonValue, path = ''): Generator<{ text: string; path: string }> {
if (typeof value === 'string') yield { text: value, path };
else if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) yield* strings(value[i], `${path}/${i}`);
} else if (value && typeof value === 'object') {
for (const [key, item] of Object.entries(value)) yield* strings(item, `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`);
}
}
const normalizeTitle = (value: string) => value.trim().replace(/\s+/g, ' ').toLowerCase();
/** Build indexes once per run, then process every source field offline. */
export function createReferenceRecognizer(catalogs: readonly ReferenceCatalogSnapshot[]) {
const indexes = catalogs.map(catalog => {
const titles = new Map<string, ReferenceCatalogEntry[]>();
for (const entry of catalog.entries) {
const key = normalizeTitle(entry.title);
titles.set(key, [...(titles.get(key) ?? []), entry]);
}
return { catalog, ids: new Map(catalog.entries.map(entry => [entry.id.toUpperCase(), entry])), titles };
});
return (fields: readonly OriginalField[]): RecognizedReference[] => {
const results: RecognizedReference[] = [];
for (const field of fields) {
const declared = declaredReferenceFramework(field.label);
const representation = typeof field.rawValue === 'string' ? 'rawValue' : 'value';
for (const { text, path } of strings(representation === 'rawValue' ? field.rawValue! : field.value)) {
for (const { catalog, ids, titles } of indexes) {
const evidence = declared === catalog.framework ? 'declared' : 'mentioned';
const occupied: Array<[number, number]> = [];
const add = (id: string, start: number, end: number, matchedBy: 'id' | 'exact-title', entry?: ReferenceCatalogEntry) => {
occupied.push([start, end]);
results.push({ framework: catalog.framework, id: entry?.id ?? id,
...(entry ? { title: entry.title, kind: entry.kind, url: entry.url } : {}),
tactics: entry?.tactics ?? [], status: entry?.state ?? 'not-in-catalog', evidence, matchedBy,
catalogVersion: catalog.version, catalogSha256: catalog.sha256,
source: { fieldKey: field.key, fieldLabel: field.label, ...(field.path ? { fieldPath: field.path } : {}),
valuePath: path, representation, start, end, offsetUnit: 'utf16', text: text.slice(start, end) } });
};
// Unicode-aware boundaries prevent partial IDs, embedded host names and longer dotted numbers.
const pattern = catalog.framework === 'mitre-attack'
? /(?<![\p{L}\p{N}_.-])(?:TA\d{4}|T\d{4}(?:\.\d{3})?)(?![\p{L}\p{N}_]|[.-][\p{L}\p{N}])/giu
: /(?<![\p{L}\p{N}_-])D3-[A-Z][A-Z0-9]*(?![\p{L}\p{N}_-])/giu;
for (const match of text.matchAll(pattern)) {
const id = match[0].toUpperCase();
add(id, match.index!, match.index! + match[0].length, 'id', ids.get(id));
}
if (evidence !== 'declared') continue;
// Only whole, explicitly delimited catalog titles; no fuzzy matches or prose inference.
for (const fragment of text.matchAll(/[^;,|/\r\n]+/g)) {
const pieces = [...fragment[0].matchAll(/[^\u2013\u2014]+/g)];
const candidates = [{ text: fragment[0], start: fragment.index! },
...pieces.map(piece => ({ text: piece[0], start: fragment.index! + piece.index! }))];
const separator = fragment[0].lastIndexOf(' - ');
if (separator >= 0) candidates.push({ text: fragment[0].slice(separator + 3), start: fragment.index! + separator + 3 });
for (const candidate of candidates) {
const trimmed = candidate.text.trim();
const start = candidate.start + candidate.text.indexOf(trimmed);
const end = start + trimmed.length;
if (!trimmed || occupied.some(([a, b]) => start < b && end > a)) continue;
const entries = titles.get(normalizeTitle(trimmed));
if (entries?.length === 1) add(entries[0].id, start, end, 'exact-title', entries[0]);
}
}
}
}
}
return results;
};
}
export function latestReferenceRuns(runs: readonly ReferenceRecognitionRun[]): ReferenceRecognitionRun[] {
const latest = new Map<string, ReferenceRecognitionRun>();
for (const run of runs) {
const previous = latest.get(run.scanRunId);
if (!previous || `${run.createdAt}\0${run.id}` > `${previous.createdAt}\0${previous.id}`) latest.set(run.scanRunId, run);
}
return [...latest.values()];
}

View File

@ -144,6 +144,8 @@ export interface ToolProfileDefinition {
schemaVersion: 1; schemaVersion: 1;
id: string; id: string;
name: string; name: string;
/** Further scan-run names the import assigns to this profile, for example scope-specific names. */
runNames?: string[];
version: string; version: string;
processing: ToolProcessingContract; processing: ToolProcessingContract;
securityScoring: ToolSecurityScoringDefinition; securityScoring: ToolSecurityScoringDefinition;
@ -237,6 +239,17 @@ export interface ToolProfileDefinition {
nextStepHeaders?: RegExp[]; nextStepHeaders?: RegExp[];
/** Retained https documentation link offered as the next step when no remediation text exists. */ /** Retained https documentation link offered as the next step when no remediation text exists. */
nextStepLinkHeaders?: RegExp[]; nextStepLinkHeaders?: RegExp[];
/**
* Native remediation subsection inside another retained field, used when no dedicated
* remediation field exists. The section runs from the native heading to the end of the field;
* the text before the heading stays in place as background.
*/
nextStepSection?: { headers: RegExp[]; heading: RegExp };
/**
* Native reference heading the scanner appends to its impact text. The impact row ends before it;
* the retained remainder is shown with the references.
*/
impactReferenceMarker?: RegExp;
/** Explanatory text shown below the next step. */ /** Explanatory text shown below the next step. */
backgroundHeaders?: RegExp[]; backgroundHeaders?: RegExp[];
/** Documentation and references shown after the background. */ /** Documentation and references shown after the background. */

View File

@ -756,11 +756,12 @@ function DashboardFactList({ items }: { items: DashboardFact[] }) {
</dl>; </dl>;
} }
function DashboardFactGroup({ title, items, glyph, snapshot, evidenceGroup }: { function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: {
title: string; items: DashboardFact[]; glyph: DashboardGlyphName; title: string; items: DashboardFact[]; glyph: DashboardGlyphName;
overviewItems?: DashboardFact[];
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup; snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
}) { }) {
const tileItems = title === 'Directory' ? items.slice(0, 6) : items; const tileItems = overviewItems ?? (title === 'Directory' ? items.slice(0, 6) : items);
return <section className="client-env-group"> return <section className="client-env-group">
<header className="client-env-group-header"> <header className="client-env-group-header">
<h3 className="client-env-group-title"> <h3 className="client-env-group-title">
@ -772,7 +773,7 @@ function DashboardFactGroup({ title, items, glyph, snapshot, evidenceGroup }: {
<EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/> <EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/>
</EnvironmentResultsButton> </EnvironmentResultsButton>
</header> </header>
<DashboardFactList items={tileItems.length ? tileItems : items.slice(0, 8)}/> <DashboardFactList items={tileItems}/>
</section>; </section>;
} }
@ -1426,6 +1427,21 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i); const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i);
const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i); const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i);
const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count'); const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count');
// Give repeated overview facts one home; Results retains the full category.
// Compare only the same named fact, never unrelated facts with equal values.
const directoryDetailLabels = ['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID'];
const countOverview = new Map([
['Domains', numberFact(dashboardEnvironmentDomainCount(environment.snapshot))],
['Domain controllers', numberFact(domainControllers)],
['User accounts', numberFact(userAccounts)],
['Computer objects', numberFact(computerAccounts)]
]);
const alreadyInData = (item: DashboardFact) => countOverview.get(item.label) === item.value;
const domainOverview = domainInformation.filter(item => !directoryDetailLabels.includes(item.label) && !alreadyInData(item));
const directoryOverview = environmentDetail.directory.filter(item =>
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
const inventoryOverview = environmentDetail.inventory.filter(item =>
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label)); const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
const entraTenants = dashboardEntraTenantCount(environment.snapshot) const entraTenants = dashboardEntraTenantCount(environment.snapshot)
?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') ?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected')
@ -1558,7 +1574,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
</section> </section>
<section className="client-domain-information" aria-labelledby="client-domain-information-heading"> <section className="client-domain-information" aria-labelledby="client-domain-information-heading">
<h2 id="client-domain-information-heading">Domain Information</h2> <h2 id="client-domain-information-heading">Domain Information</h2>
<dl>{domainInformation.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl> <dl>{domainOverview.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
</section> </section>
</aside> </aside>
</div> </div>
@ -1566,9 +1582,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />} {scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<div className="client-environment-grid" aria-label="Environment overview"> <div className="client-environment-grid" aria-label="Environment overview">
<DashboardFactGroup title="Directory" items={environmentDetail.directory} glyph="domain" snapshot={environment.snapshot} evidenceGroup="directory" /> <DashboardFactGroup title="Directory" items={environmentDetail.directory} overviewItems={directoryOverview} glyph="domain" snapshot={environment.snapshot} evidenceGroup="directory" />
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" /> <DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" /> <DashboardFactGroup title="Inventory" items={environmentDetail.inventory} overviewItems={inventoryOverview} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" />
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" /> <DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" />
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" /> <DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" />
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} /> <OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} />

View File

@ -11,6 +11,8 @@ import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { resultReferenceValues } from '../../profiles/purple-knight/result-linker'; import { resultReferenceValues } from '../../profiles/purple-knight/result-linker';
import { maesterFindingTitle } from '../../profiles/maester/presentation'; import { maesterFindingTitle } from '../../profiles/maester/presentation';
import { cisBenchmarkRuleId } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisSectionHeading } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links'; import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
import { recordResultSheets } from '../linked-results/record-results'; import { recordResultSheets } from '../linked-results/record-results';
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
@ -231,9 +233,10 @@ export function DatasetViewer() {
const recordTitle = (record: FindingRecord) => { const recordTitle = (record: FindingRecord) => {
const candidate = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title; const candidate = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title;
return toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId) === 'maester' const recordToolId = toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId);
? maesterFindingTitle(record, candidate) if (recordToolId === 'maester') return maesterFindingTitle(record, candidate);
: candidate; // CIS headings name their benchmark section instead of the rule number, which stays in Source Rule ID.
return recordToolId === 'cis-cat' ? cisSectionHeading(record, cisBenchmarkRuleId(record, recordToolId), candidate) : candidate;
}; };
const selected = records.find(record => record.id === selectedRecordId) ?? records[0]; const selected = records.find(record => record.id === selectedRecordId) ?? records[0];
const toolId = activeSheet.toolId ?? dataset.profileId; const toolId = activeSheet.toolId ?? dataset.profileId;

View File

@ -1,7 +1,7 @@
import { RichTextValue } from '../../components/RichTextValue/RichTextValue'; import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { highlightText } from '../../components/HighlightText/highlight-text'; import { highlightText } from '../../components/HighlightText/highlight-text';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
import type { FindingFirstRead as Summary } from './first-read'; import { powerShellDisplayLines, type FindingFirstRead as Summary } from './first-read';
/** Scanner first read: what the scanner found here, why it matters, and what to do next. */ /** Scanner first read: what the scanner found here, why it matters, and what to do next. */
export function FindingFirstRead({ summary, outcome, search, onResults }: { export function FindingFirstRead({ summary, outcome, search, onResults }: {
@ -20,7 +20,7 @@ export function FindingFirstRead({ summary, outcome, search, onResults }: {
{(summary.nextStep || summary.link) && <section className="policy-next-step finding-first-read" aria-label="Next step"> {(summary.nextStep || summary.link) && <section className="policy-next-step finding-first-read" aria-label="Next step">
<h4>Next step</h4> <h4>Next step</h4>
{summary.nextStep?.lead && <RichTextValue value={summary.nextStep.lead} search={search} label={summary.nextStep.label} onResults={onResults} />} {summary.nextStep?.lead && <RichTextValue value={summary.nextStep.lead} search={search} label={summary.nextStep.label} onResults={onResults} />}
{summary.nextStep?.command && <pre className="finding-first-read__command"><code>{highlightText(summary.nextStep.command, search)}</code></pre>} {summary.nextStep?.command && <pre className="finding-first-read__command"><code>{highlightText(powerShellDisplayLines(summary.nextStep.command).join('\n'), search)}</code></pre>}
{summary.nextStep?.rest && <details className="finding-optional-detail"> {summary.nextStep?.rest && <details className="finding-optional-detail">
<summary><TriangleIcon direction="right" />Full remediation</summary> <summary><TriangleIcon direction="right" />Full remediation</summary>
<RichTextValue value={summary.nextStep.rest} search={search} label={summary.nextStep.label} onResults={onResults} /> <RichTextValue value={summary.nextStep.rest} search={search} label={summary.nextStep.label} onResults={onResults} />

View File

@ -9,7 +9,7 @@ import { PolicySourceEvidence } from './PolicySourceEvidence';
import { PolicyBaselineComparison } from './PolicyBaselineComparison'; import { PolicyBaselineComparison } from './PolicyBaselineComparison';
import { PolicyProblemSummary } from './PolicyProblemSummary'; import { PolicyProblemSummary } from './PolicyProblemSummary';
import { FindingFirstRead } from './FindingFirstRead'; import { FindingFirstRead } from './FindingFirstRead';
import { findingFirstRead, firstReadConsumedFields, firstReadFieldRank } from './first-read'; import { findingFirstRead, firstReadConsumedFields, firstReadFieldRank, firstReadImpactSplit, firstReadSectionSplit } from './first-read';
import { policyProblemSummary } from '../../profiles/group-policy/problem-summary'; import { policyProblemSummary } from '../../profiles/group-policy/problem-summary';
import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation'; import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
import { RichTextValue } from '../../components/RichTextValue/RichTextValue'; import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
@ -23,6 +23,7 @@ import { BookmarkIcon } from '../../components/BookmarkIcon';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message'; import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation'; import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation';
import { policyDiagnosticBadges, policyFindingCategories } from '../../profiles/group-policy/categories'; import { policyDiagnosticBadges, policyFindingCategories } from '../../profiles/group-policy/categories';
import { findingWebSearch } from '../../profiles/public-search/search'; import { findingWebSearch } from '../../profiles/public-search/search';
@ -268,6 +269,17 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
// Scanner first read (user decision 2026-09-25): problem, impact and next step are shown above, // Scanner first read (user decision 2026-09-25): problem, impact and next step are shown above,
// so their fields are not repeated; background precedes references. // so their fields are not repeated; background precedes references.
if (toolId !== 'group-policy') firstReadConsumedFields(record, toolId).forEach(field => hiddenFieldIndexes.add(field.index)); if (toolId !== 'group-policy') firstReadConsumedFields(record, toolId).forEach(field => hiddenFieldIndexes.add(field.index));
// A native remediation subsection moved to the next step is not repeated; the text before it stays as background.
// References a scanner appended to its impact text move to the references position.
const sectionSplit = toolId !== 'group-policy' ? firstReadSectionSplit(record, toolId) : undefined;
const impactSplit = toolId !== 'group-policy' ? firstReadImpactSplit(record, toolId) : undefined;
const sectionBackground: RecordField[] = [];
let firstReadIndex = Math.max(-1, ...record.fields.map(field => field.index), ...maesterPresentationFields.map(field => field.index)) + 1;
if (sectionSplit) {
hiddenFieldIndexes.add(sectionSplit.field.index);
if (sectionSplit.background) sectionBackground.push({ ...sectionSplit.field, value: sectionSplit.background, index: firstReadIndex++ });
}
if (impactSplit?.references) sectionBackground.push({ ...impactSplit.field, label: 'References', value: impactSplit.references, index: firstReadIndex++ });
const summaryPriority = (label: string) => { const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label); const normalized = normalizeLabel(label);
const firstReadRank = toolId !== 'group-policy' ? firstReadFieldRank(label, toolId) : undefined; const firstReadRank = toolId !== 'group-policy' ? firstReadFieldRank(label, toolId) : undefined;
@ -290,7 +302,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
return 10; return 10;
}; };
const summaryValues = new Set<string>(); const summaryValues = new Set<string>();
const presentationFields = [...record.fields, ...maesterPresentationFields]; const presentationFields = [...record.fields, ...maesterPresentationFields, ...sectionBackground];
const maesterPresentationIndexes = new Set(maesterPresentationFields.map(field => field.index)); const maesterPresentationIndexes = new Set(maesterPresentationFields.map(field => field.index));
const summaryFields = presentationFields const summaryFields = presentationFields
.filter(field => { .filter(field => {
@ -413,7 +425,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist {/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */} `Investigate` optisch nicht von `Passed` zu unterscheiden. */}
{statusField?.value && <span className="outcome-mark" data-outcome={outcomeForStatusValue(resolvedToolId, statusField.value)}>{highlightText(statusField.value, search)}</span>} {statusField?.value && <span className="outcome-mark" data-outcome={outcomeForStatusValue(resolvedToolId, statusField.value)}>{highlightText(statusField.value, search)}</span>}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" key={category}>{highlightText(category, search)}</span>)} {/* The heading already names the CIS section; each number badge names its section on hover. */}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" title={cisCategoryRecordTitle(record, category)} key={category}>{highlightText(category, search)}</span>)}
{badges.map(field => { {badges.map(field => {
const isSeverity = field.label.toLowerCase() === 'severity'; const isSeverity = field.label.toLowerCase() === 'severity';
const scale = /^(?:category score|domain risk level)$/i.test(field.label.trim()) ? ' / 100' : ''; const scale = /^(?:category score|domain risk level)$/i.test(field.label.trim()) ? ' / 100' : '';
@ -490,6 +503,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
</details>)} </details>)}
</section> </section>
{/* Work tracking is opt-in below the finding so the first screen states the problem. */} {/* Work tracking is opt-in below the finding so the first screen states the problem. */}
<FindingReferences record={record}/>
<details className="reader-tracking"> <details className="reader-tracking">
<summary><TriangleIcon direction="right" />Work tracking<span className="reader-tracking__state"> <summary><TriangleIcon direction="right" />Work tracking<span className="reader-tracking__state">
{[`Status: ${review.status}`, `Priority: ${review.priority}`, review.owner && `Owner: ${review.owner}`, review.due && `Due: ${review.due}`].filter(Boolean).join(' · ')} {[`Status: ${review.status}`, `Priority: ${review.priority}`, review.owner && `Owner: ${review.owner}`, review.due && `Due: ${review.due}`].filter(Boolean).join(' · ')}
@ -535,3 +549,4 @@ export function FindingReader({ record, title, review: storedReview, previousPol
</article> </article>
); );
} }
import { FindingReferences } from './FindingReferences';

View File

@ -0,0 +1,30 @@
import type { FindingRecord } from '../../domain/finding';
import { TriangleIcon } from '../../components/TriangleIcon';
import { PathText } from '../../components/PathText/PathText';
const labels = { active: 'Catalog match', deprecated: 'Deprecated', revoked: 'Revoked', 'not-in-catalog': 'Not found in this catalog' };
export function FindingReferences({ record }: { record: FindingRecord }) {
const recognition = record.referenceRecognition;
if (!recognition) return record.findingOccurrenceId ? <section className="reference-recognition"><h4>Catalog references</h4>
<p>Not indexed yet. Use “Recognize references” in this client’s Data Hub.</p></section> : null;
if (!recognition.references.length) return null;
return <section className="reference-recognition" aria-label="Catalog references">
<h4>Catalog references</h4>
<p>Declared: stated by the source. Mentioned: found elsewhere in the finding text.</p>
{recognition.references.map((reference, index) => <details className="finding-optional-detail" key={index}>
<summary><TriangleIcon direction="right"/>{reference.framework === 'mitre-attack' ? 'ATT&CK' : 'D3FEND'} {reference.id}{reference.title ? ` · ${reference.title}` : ''} · {reference.evidence === 'declared' ? 'Declared' : 'Mentioned'} · {labels[reference.status]}</summary>
<table><tbody>
<tr><th>Original text</th><td>{reference.source.text}</td></tr>
<tr><th>Source field</th><td>{reference.source.fieldLabel} · {reference.source.fieldKey}</td></tr>
{reference.source.fieldPath && <tr><th>Source path</th><td><PathText value={reference.source.fieldPath}/></td></tr>}
<tr><th>Text position</th><td>{reference.source.representation}{reference.source.valuePath} · {reference.source.start}–{reference.source.end} (UTF-16; end excluded)</td></tr>
<tr><th>Catalog version</th><td>{reference.catalogVersion}</td></tr>
<tr><th>Catalog SHA-256</th><td>{reference.catalogSha256}</td></tr>
<tr><th>Recognition</th><td>{recognition.recognizerVersion} · {recognition.createdAt} · {reference.matchedBy}</td></tr>
<tr><th>Run</th><td>{recognition.runId}</td></tr>
{reference.url && /^https:\/\/(?:attack\.mitre\.org|d3fend\.mitre\.org)\//.test(reference.url)
&& <tr><th>Catalog entry</th><td><a href={reference.url} target="_blank" rel="noopener noreferrer">{reference.url}</a></td></tr>}
</tbody></table>
</details>)}
</section>;
}

View File

@ -43,6 +43,40 @@ export function firstReadConsumedFields(record: FindingRecord, toolId?: string):
.map(patterns => fieldFor(record, patterns)).filter((field): field is RecordField => Boolean(field)); .map(patterns => fieldFor(record, patterns)).filter((field): field is RecordField => Boolean(field));
} }
/**
* A declared native remediation subsection inside another field, used only when no dedicated
* remediation field exists. `background` is the retained text before the native heading.
*/
export function firstReadSectionSplit(record: FindingRecord, toolId?: string):
{ field: RecordField; background: string; section: string } | undefined {
const definition = firstReadDefinition(toolId);
if (!definition?.nextStepSection || fieldFor(record, definition.nextStepHeaders)) return undefined;
const field = fieldFor(record, definition.nextStepSection.headers);
const heading = field ? definition.nextStepSection.heading.exec(field.value) : null;
if (!field || !heading) return undefined;
const section = field.value.slice(heading.index + heading[0].length).trim();
return section ? { field, background: field.value.slice(0, heading.index).trim(), section } : undefined;
}
/** Impact text before a declared native reference heading, and the retained references after it. */
export function firstReadImpactSplit(record: FindingRecord, toolId?: string):
{ field: RecordField; impact: string; references: string } | undefined {
const definition = firstReadDefinition(toolId);
const field = fieldFor(record, definition?.impactHeaders);
const marker = field && definition?.impactReferenceMarker ? definition.impactReferenceMarker.exec(field.value) : null;
if (!field || !marker) return undefined;
const references = field.value.slice(marker.index + marker[0].length).trim();
return { field, impact: field.value.slice(0, marker.index).trim(), references };
}
/**
* Display paragraphs of prose whose paragraph breaks were lost in the export (`privileges.An attacker`).
* Only a sentence end directly followed by a capitalized word gets a break; no character is removed.
*/
export function restoredParagraphs(text: string): string {
return /\n/.test(text) ? text : text.replace(/([a-z][.!?])(?=[A-Z][a-z])/g, '$1\n\n');
}
/** Ordering of the remaining explanatory fields: background first, references last. */ /** Ordering of the remaining explanatory fields: background first, references last. */
export function firstReadFieldRank(label: string, toolId?: string): number | undefined { export function firstReadFieldRank(label: string, toolId?: string): number | undefined {
const definition = firstReadDefinition(toolId); const definition = firstReadDefinition(toolId);
@ -58,14 +92,72 @@ export function firstReadFieldRank(label: string, toolId?: string): number | und
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;
const rowText = (row: FindingRecord) => row.fields.map(field => field.value.trim()).filter(Boolean).join(' · '); const rowText = (row: FindingRecord) => row.fields.map(field => field.value.trim()).filter(Boolean).join(' · ');
/** Present an explicitly introduced PowerShell command separately without changing the retained source field. */ /**
* Present an explicitly introduced PowerShell command separately without changing the retained source field.
* The introduction names a command or script within the same sentence and ends with a colon; the command
* starts with a cmdlet (`Verb-Noun`) or a variable assignment (`$name =`).
*/
function separatePowerShellCommand(paragraph: string): { lead: string; command?: string } { function separatePowerShellCommand(paragraph: string): { lead: string; command?: string } {
const introduction = /\b(?:PowerShell\s+)?command:\s*(?=(?:Get|Set|New|Remove|Enable|Disable|Test|Import|Export)-[A-Za-z][\w-]*\b)/i.exec(paragraph); const introduction = /\b(?:[Pp]ower[Ss]hell\s+)?(?:[Cc]ommand|[Ss]cript)\b[^:.]{0,160}:\s*(?=(?:[A-Z][a-z]+-[A-Z][\w-]*\b|\$[A-Za-z_]\w*\s*=))/.exec(paragraph);
if (!introduction || introduction.index === undefined) return { lead: paragraph }; if (!introduction || introduction.index === undefined) return { lead: paragraph };
const command = paragraph.slice(introduction.index + introduction[0].length).trim(); const command = paragraph.slice(introduction.index + introduction[0].length).trim();
return command ? { lead: paragraph.slice(0, introduction.index + introduction[0].trimEnd().length), command } : { lead: paragraph }; return command ? { lead: paragraph.slice(0, introduction.index + introduction[0].trimEnd().length), command } : { lead: paragraph };
} }
const CMDLET_START = /^[A-Z][a-z]+-[A-Z][\w-]*\b/;
/**
* Display layout of a PowerShell command whose line breaks were lost in the export. Only whitespace
* at the top nesting level outside quotes changes: a new statement that starts with a cmdlet after a
* closing bracket begins a new line, and a pipeline continues on an indented line after `|`.
* Every other character stays as retained, so the displayed text remains valid, copyable PowerShell.
*/
export function powerShellDisplayLines(command: string): string[] {
// A command that kept its own line breaks is shown exactly as retained.
if (/\n/.test(command)) return command.split(/\r?\n/);
const lines: string[] = [];
let current = '';
let depth = 0;
let quote = '';
let continuation = false;
const flush = () => {
if (current.trim()) lines.push(`${continuation ? ' ' : ''}${current.trim()}`);
current = '';
};
for (let index = 0; index < command.length; index += 1) {
const character = command[index];
if (quote) {
current += character;
if (character === quote && command[index - 1] !== '`') quote = '';
continue;
}
if (character === '"' || character === "'") { quote = character; current += character; continue; }
if ('({['.includes(character)) depth += 1;
if (')}]'.includes(character)) depth = Math.max(0, depth - 1);
if (depth === 0 && character === '|' && command[index + 1] !== '|' && command[index - 1] !== '|') {
current += character;
flush();
continuation = true;
while (/\s/.test(command[index + 1] ?? '')) index += 1;
continue;
}
if (depth === 0 && /\s/.test(character)) {
const previous = current.trimEnd().slice(-1);
const rest = command.slice(index).trimStart();
// Only after a closed expression: a cmdlet name after a plain word can be an argument (`Get-Help Get-ADUser`).
if (previous && /[)\]}]/.test(previous) && CMDLET_START.test(rest)) {
flush();
continuation = false;
index += command.slice(index).length - rest.length - 1;
continue;
}
}
current += character;
}
flush();
return lines;
}
export function findingFirstRead( export function findingFirstRead(
record: FindingRecord, toolId: string | undefined, linkedSheets: DatasetSheet[], outcome?: CanonicalOutcome record: FindingRecord, toolId: string | undefined, linkedSheets: DatasetSheet[], outcome?: CanonicalOutcome
): FindingFirstRead | undefined { ): FindingFirstRead | undefined {
@ -100,9 +192,12 @@ export function findingFirstRead(
} }
const impact = fieldFor(record, definition.impactHeaders); const impact = fieldFor(record, definition.impactHeaders);
if (impact) rows.push({ label: 'Impact', value: impact.value.trim() }); const impactText = impact ? firstReadImpactSplit(record, toolId)?.impact ?? impact.value.trim() : '';
if (impactText) rows.push({ label: 'Impact', value: restoredParagraphs(impactText) });
const remediation = fieldFor(record, definition.nextStepHeaders); const field = fieldFor(record, definition.nextStepHeaders);
const section = field ? undefined : firstReadSectionSplit(record, toolId);
const remediation = field ? { label: field.label, value: field.value } : section ? { label: section.field.label, value: section.section } : undefined;
const [lead, ...rest] = remediation ? remediation.value.trim().split(/\n\s*\n/) : []; const [lead, ...rest] = remediation ? remediation.value.trim().split(/\n\s*\n/) : [];
const nextStep = remediation ? { label: remediation.label, ...separatePowerShellCommand(lead.trim()), rest: rest.join('\n\n').trim() } : undefined; const nextStep = remediation ? { label: remediation.label, ...separatePowerShellCommand(lead.trim()), rest: rest.join('\n\n').trim() } : undefined;
const linkField = fieldFor(record, definition.nextStepLinkHeaders); const linkField = fieldFor(record, definition.nextStepLinkHeaders);

View File

@ -10,10 +10,8 @@ import { inlineResultTableSheets } from '../linked-results/inline-result-tables'
import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message';
import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { useFilteredRecords } from '../search-filter/use-filtered-records';
import { toolIdForRecord } from '../../importers/status-normalization'; import { toolIdForRecord } from '../../importers/status-normalization';
import mitreData from '../../assets/data/mitre-enterprise-v18.1.json'; import { activeMitreMatrix, buildFindingPathItems, MITRE_MATRIX_CHANGED_EVENT, type FindingPathItem, type MitreGraphData } from './mitre-graph';
import { buildFindingPathItems, loadStoredMitreMatrix, type FindingPathItem, type MitreGraphData } from './mitre-graph';
const defaultGraphData = mitreData as MitreGraphData;
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const; const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster. // Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
// Enthielten zwei violette Toene (#7c3aed, #6d28d9); ersetzt, weil violett als // Enthielten zwei violette Toene (#7c3aed, #6d28d9); ersetzt, weil violett als
@ -77,7 +75,12 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
updateReview updateReview
} = app; } = app;
const [graphData, setGraphData] = useState<MitreGraphData>(() => loadStoredMitreMatrix() ?? defaultGraphData); const [graphData, setGraphData] = useState<MitreGraphData>(activeMitreMatrix);
useEffect(() => {
const refreshMatrix = () => setGraphData(activeMitreMatrix());
globalThis.addEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
return () => globalThis.removeEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
}, []);
const [category, setCategory] = useState('all'); const [category, setCategory] = useState('all');
const [severity, setSeverity] = useState<SeverityFilter>('all'); const [severity, setSeverity] = useState<SeverityFilter>('all');
const [originFilter, setOriginFilter] = useState<OriginFilter>('all'); const [originFilter, setOriginFilter] = useState<OriginFilter>('all');

View File

@ -12,14 +12,12 @@ import { ViewBookmarks } from '../view-bookmarks/ViewBookmarks';
import type { PathSnapshotState, ViewBookmark } from '../view-bookmarks/view-bookmarks'; import type { PathSnapshotState, ViewBookmark } from '../view-bookmarks/view-bookmarks';
import { toolIdForRecord } from '../../importers/status-normalization'; import { toolIdForRecord } from '../../importers/status-normalization';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog'; import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import mitreData from '../../assets/data/mitre-enterprise-v18.1.json'; import { activeMitreMatrix, buildFindingPathItems, MITRE_MATRIX_CHANGED_EVENT, type FindingPathItem, type MitreGraphData } from './mitre-graph';
import { buildFindingPathItems, loadStoredMitreMatrix, type FindingPathItem, type MitreGraphData } from './mitre-graph';
import { buildObsidianGraph, type ObsidianGraphNode } from './obsidian-graph'; import { buildObsidianGraph, type ObsidianGraphNode } from './obsidian-graph';
import { hiddenPathfinderFacetLabels } from './pathfinder-filter-trail'; import { hiddenPathfinderFacetLabels } from './pathfinder-filter-trail';
import { FILTERED_GRAPH_SELECTION_ID, graphSelectionUnionNodeIds, selectedPathNodeIds } from './category-branch'; import { FILTERED_GRAPH_SELECTION_ID, graphSelectionUnionNodeIds, selectedPathNodeIds } from './category-branch';
import { graphFacetSelectionForNode, relationNodeIdForFacetFilters, type GraphFacetSelection } from './graph-filter-bridge'; import { graphFacetSelectionForNode, relationNodeIdForFacetFilters, type GraphFacetSelection } from './graph-filter-bridge';
const defaultGraphData = mitreData as MitreGraphData;
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const; const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
const graphNodeKindLabels = { finding: 'Finding', category: 'Category', defense: 'D3FEND', target: 'Target', technique: 'Technique', tactic: 'Tactic' } as const; const graphNodeKindLabels = { finding: 'Finding', category: 'Category', defense: 'D3FEND', target: 'Target', technique: 'Technique', tactic: 'Tactic' } as const;
// Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster. // Fensterunterscheidungsfarben fuer gleichzeitig geoeffnete Result-Fenster.
@ -67,7 +65,12 @@ export const ObsidianGraphView = memo(ObsidianGraphContent);
function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPathSnapshotApplied }: ObsidianGraphViewProps) { function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPathSnapshotApplied }: ObsidianGraphViewProps) {
const app = useApp(); const app = useApp();
const { dataset, activeSheet, activeSheetId, activeProfile, titleColumn, reviews, actionOnly, facetFilters, pathFindingIds, search: workSearch, statusFilter, setFacetFilter, setSelectedRecord, setView, toggleWorkspace, updateReview } = app; const { dataset, activeSheet, activeSheetId, activeProfile, titleColumn, reviews, actionOnly, facetFilters, pathFindingIds, search: workSearch, statusFilter, setFacetFilter, setSelectedRecord, setView, toggleWorkspace, updateReview } = app;
const [graphData] = useState<MitreGraphData>(() => loadStoredMitreMatrix() ?? defaultGraphData); const [graphData, setGraphData] = useState<MitreGraphData>(activeMitreMatrix);
useEffect(() => {
const refreshMatrix = () => setGraphData(activeMitreMatrix());
globalThis.addEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
return () => globalThis.removeEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
}, []);
const [selectedNodeId, setSelectedNodeId] = useState(''); const [selectedNodeId, setSelectedNodeId] = useState('');
const [pinnedNodeIds, setPinnedNodeIds] = useState<string[]>([]); const [pinnedNodeIds, setPinnedNodeIds] = useState<string[]>([]);
const [hoveredNodeId, setHoveredNodeId] = useState(''); const [hoveredNodeId, setHoveredNodeId] = useState('');

View File

@ -1,6 +1,9 @@
import { findingReferences } from '../../references/finding-reference-projection';
import type { FindingRecord, Severity } from '../../domain/finding'; import type { FindingRecord, Severity } from '../../domain/finding';
import { mitreBadgeLabel, normalizeSeverity } from '../../profiles/purple-knight/presentation'; import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import mitreData from '../../assets/data/mitre-enterprise-v19.2.json';
import { convertEnterpriseStix } from './mitre-stix';
export interface MitreTechnique { export interface MitreTechnique {
id: string; id: string;
@ -51,14 +54,31 @@ export interface FindingPathItem {
} }
const STORAGE_KEY = 'assessor_custom_mitre_matrix'; const STORAGE_KEY = 'assessor_custom_mitre_matrix';
export const defaultMitreGraphData = mitreData as MitreGraphData;
export const MITRE_MATRIX_CHANGED_EVENT = 'assessor:mitre-matrix-changed';
let serverMitreGraphData: MitreGraphData | null = null;
export function setServerMitreMatrix(data: MitreGraphData): void {
const validated = validateMitreMatrixData(data);
const [major, minor] = validated.version.split('.').map(Number);
const [currentMajor, currentMinor] = (serverMitreGraphData?.version ?? defaultMitreGraphData.version).split('.').map(Number);
if (major < currentMajor || (major === currentMajor && minor < currentMinor)) return;
serverMitreGraphData = validated;
globalThis.dispatchEvent?.(new Event(MITRE_MATRIX_CHANGED_EVENT));
}
export function validateMitreMatrixData(raw: unknown): MitreGraphData { export function validateMitreMatrixData(raw: unknown): MitreGraphData {
if (!raw || typeof raw !== 'object') throw new Error('Invalid MITRE matrix format: expected a JSON object.'); if (!raw || typeof raw !== 'object') throw new Error('Invalid MITRE matrix format: expected a JSON object.');
if ('type' in raw && raw.type === 'bundle') return convertEnterpriseStix(raw);
const data = raw as Partial<MitreGraphData>; const data = raw as Partial<MitreGraphData>;
if (typeof data.version !== 'string' || !/^\d+\.\d+$/.test(data.version)) throw new Error('Invalid MITRE matrix: a version is required.');
if (!Array.isArray(data.tacticOrder) || !data.tacticOrder.length) throw new Error('Invalid MITRE matrix: "tacticOrder" array is missing or empty.'); if (!Array.isArray(data.tacticOrder) || !data.tacticOrder.length) throw new Error('Invalid MITRE matrix: "tacticOrder" array is missing or empty.');
if (!Array.isArray(data.techniques)) throw new Error('Invalid MITRE matrix: "techniques" array is missing.'); if (!Array.isArray(data.techniques) || !data.techniques.length) throw new Error('Invalid MITRE matrix: "techniques" array is missing or empty.');
if (data.techniques.some(t => !t || typeof t.id !== 'string' || !/^T\d{4}(?:\.\d{3})?$/.test(t.id) || typeof t.name !== 'string' || !t.name.trim())) {
throw new Error('Invalid MITRE matrix: a technique ID or name is invalid.');
}
return { return {
version: String(data.version ?? 'Updated'), version: data.version,
tacticOrder: data.tacticOrder.map(String), tacticOrder: data.tacticOrder.map(String),
techniques: data.techniques.map(t => ({ techniques: data.techniques.map(t => ({
id: String(t.id), id: String(t.id),
@ -91,12 +111,9 @@ export function clearStoredMitreMatrix(): void {
globalThis.localStorage?.removeItem(STORAGE_KEY); globalThis.localStorage?.removeItem(STORAGE_KEY);
} }
const simplify = (value: string) => value export function activeMitreMatrix(): MitreGraphData {
.trim() return loadStoredMitreMatrix() ?? serverMitreGraphData ?? defaultMitreGraphData;
.toLocaleLowerCase('en-US') }
.replace(/&/g, 'and')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
const numberField = (record: FindingRecord, pattern: RegExp) => { const numberField = (record: FindingRecord, pattern: RegExp) => {
const value = record.fields.find(field => pattern.test(field.label.trim()))?.value ?? ''; const value = record.fields.find(field => pattern.test(field.label.trim()))?.value ?? '';
@ -116,21 +133,14 @@ const severityRisk: Record<Severity, number> = {
export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder: string[]): FindingMitreFocus { export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder: string[]): FindingMitreFocus {
const techniqueIds = new Set<string>(); const techniqueIds = new Set<string>();
const tactics = new Set<string>(); const tactics = new Set<string>();
const tacticLookup = new Map(tacticOrder.map(tactic => [simplify(tactic), tactic])); const graph = { ...defaultMitreGraphData, tacticOrder };
let findingCount = 0; let findingCount = 0;
for (const record of records) { for (const record of records) {
const fields = record.fields.filter(field => mitreBadgeLabel(field.label) && field.value); const references = findingReferences(record, graph).filter(ref => ref.framework === 'mitre-attack' && ref.status === 'active');
if (!fields.length) continue; if (references.length) findingCount++;
findingCount += 1; for (const ref of references) {
for (const field of fields) { if (ref.kind === 'technique') techniqueIds.add(ref.id);
for (const match of field.value.toUpperCase().matchAll(/\bT\d{4}(?:\.\d{3})?\b/g)) techniqueIds.add(match[0]); if (ref.kind === 'tactic') tactics.add(ref.title!);
const fragments = field.value.split(/[,;|/]+/);
for (const fragment of fragments) {
const normalized = simplify(fragment.replace(/\bT\d{4}(?:\.\d{3})?\b/gi, ''));
const tactic = tacticLookup.get(normalized);
if (tactic) tactics.add(tactic);
}
} }
} }
@ -138,30 +148,14 @@ export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder:
} }
export function buildFindingPathItems(records: FindingRecord[], data: MitreGraphData, titleColumn = '', fallbackToolId?: string): FindingPathItem[] { export function buildFindingPathItems(records: FindingRecord[], data: MitreGraphData, titleColumn = '', fallbackToolId?: string): FindingPathItem[] {
const tacticLookup = new Map(data.tacticOrder.map(tactic => [simplify(tactic), tactic]));
const techniqueLookup = new Map(data.techniques.map(technique => [technique.id, technique]));
const headerKinds = new Map<string, string>();
const headerKind = (label: string) => {
let kind = headerKinds.get(label);
if (kind === undefined) { kind = mitreBadgeLabel(label); headerKinds.set(label, kind); }
return kind;
};
return records.map(record => { return records.map(record => {
const mitreFields = record.fields.filter(field => field.value && headerKind(field.label)); const references = findingReferences(record, data).filter(ref => ref.status === 'active');
const attackFields = mitreFields.filter(field => headerKind(field.label) === 'MITRE ATT&CK'); const attackReferences = references.filter(ref => ref.framework === 'mitre-attack');
const defenseFields = mitreFields.filter(field => headerKind(field.label) !== 'MITRE ATT&CK'); const hasNativeMitreField = attackReferences.some(ref => ref.evidence === 'declared');
const hasNativeMitreField = attackFields.length > 0; const techniqueIds = [...new Set(attackReferences.filter(ref => ref.kind === 'technique').map(ref => ref.id))];
const techniqueIds = [...new Set(attackFields.flatMap(field => [...field.value.toUpperCase().matchAll(/\bT\d{4}(?:\.\d{3})?\b/g)].map(match => match[0])))]; const tactics = new Set(attackReferences.flatMap(ref => ref.tactics));
const tactics = new Set<string>(); const orderedTactics = [...data.tacticOrder.filter(tactic => tactics.has(tactic)),
for (const id of techniqueIds) for (const tactic of techniqueLookup.get(id)?.tactics ?? []) tactics.add(tactic); ...[...tactics].filter(tactic => !data.tacticOrder.includes(tactic))];
for (const field of attackFields) {
for (const fragment of field.value.split(/[,;|/]+/)) {
const tactic = tacticLookup.get(simplify(fragment.replace(/\bT\d{4}(?:\.\d{3})?\b/gi, '')));
if (tactic) tactics.add(tactic);
}
}
const orderedTactics = data.tacticOrder.filter(tactic => tactics.has(tactic));
const severityValue = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label.trim()))?.value; const severityValue = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label.trim()))?.value;
const severity = normalizeSeverity(severityValue); const severity = normalizeSeverity(severityValue);
const weight = numberField(record, /^weight$/i); const weight = numberField(record, /^weight$/i);
@ -176,10 +170,7 @@ export function buildFindingPathItems(records: FindingRecord[], data: MitreGraph
...cisBenchmarkCategories(record, fallbackToolId) ...cisBenchmarkCategories(record, fallbackToolId)
])]; ])];
const category = categories[0] ?? ''; const category = categories[0] ?? '';
const defenses = [...new Set(defenseFields const defenses = [...new Set(references.filter(ref => ref.framework === 'mitre-d3fend').map(ref => `${ref.id} · ${ref.title}`))];
.flatMap(field => field.value.split(/[;|]+/))
.map(value => value.trim())
.filter(Boolean))];
const targets = [...new Set(record.fields const targets = [...new Set(record.fields
.filter(field => /^(?:target|ziel|betroffenes objekt|asset|host|hostname|domain|resource|system)$/i.test(field.label.trim()) && field.value) .filter(field => /^(?:target|ziel|betroffenes objekt|asset|host|hostname|domain|resource|system)$/i.test(field.label.trim()) && field.value)
.flatMap(field => field.value.split(/[;|]+/)) .flatMap(field => field.value.split(/[;|]+/))

View File

@ -0,0 +1,87 @@
import type { MitreGraphData, MitreEntity, MitreTechnique } from './mitre-graph';
interface StixObject {
id?: string;
type?: string;
name?: string;
revoked?: boolean;
x_mitre_deprecated?: boolean;
x_mitre_version?: string;
x_mitre_shortname?: string;
x_mitre_platforms?: string[];
tactic_refs?: string[];
kill_chain_phases?: { kill_chain_name?: string; phase_name?: string }[];
external_references?: { source_name?: string; external_id?: string; url?: string }[];
relationship_type?: string;
source_ref?: string;
target_ref?: string;
}
const active = (item: StixObject) => !item.revoked && !item.x_mitre_deprecated;
const attackReference = (item: StixObject) => item.external_references?.find(reference => reference.source_name === 'mitre-attack');
const byId = (left: { id: string }, right: { id: string }) => left.id.localeCompare(right.id);
/** Convert MITRE's Enterprise ATT&CK STIX 2.1 bundle to the compact Pathfinder catalog. */
export function convertEnterpriseStix(raw: unknown): MitreGraphData {
if (!raw || typeof raw !== 'object' || !('type' in raw) || raw.type !== 'bundle'
|| !('objects' in raw) || !Array.isArray(raw.objects)) {
throw new Error('Expected an Enterprise ATT&CK STIX bundle.');
}
const objects = raw.objects as StixObject[];
const collection = objects.find(item => item.type === 'x-mitre-collection' && item.name === 'Enterprise ATT&CK');
const matrix = objects.find(item => item.type === 'x-mitre-matrix' && item.name === 'Enterprise ATT&CK' && active(item));
if (!collection?.x_mitre_version || !matrix?.tactic_refs?.length) {
throw new Error('Enterprise ATT&CK collection version or tactic matrix is missing.');
}
const tactics = new Map(objects.filter(item => item.type === 'x-mitre-tactic' && active(item)).map(item => [item.id, item]));
const ordered = matrix.tactic_refs.map(id => tactics.get(id));
if (ordered.some(item => !item?.name || !item.x_mitre_shortname)) {
throw new Error('Enterprise ATT&CK matrix references an unavailable tactic.');
}
const tacticOrder = ordered.map(item => item!.name!);
const tacticNames = new Map(ordered.map(item => [item!.x_mitre_shortname!, item!.name!]));
const techniqueStixIds = new Map<string, string>();
const techniques: MitreTechnique[] = objects.filter(item => item.type === 'attack-pattern' && active(item)).flatMap(item => {
const reference = attackReference(item);
if (!item.id || !item.name || !reference?.external_id || !/^T\d{4}(?:\.\d{3})?$/.test(reference.external_id)) return [];
const mappedTactics = [...new Set((item.kill_chain_phases ?? [])
.filter(phase => phase.kill_chain_name === 'mitre-attack')
.map(phase => tacticNames.get(phase.phase_name ?? ''))
.filter((name): name is string => Boolean(name)))];
if (!mappedTactics.length) return [];
techniqueStixIds.set(item.id, reference.external_id);
return [{
id: reference.external_id,
name: item.name,
tactics: tacticOrder.filter(name => mappedTactics.includes(name)),
platforms: Array.isArray(item.x_mitre_platforms) ? item.x_mitre_platforms : [],
parent: reference.external_id.includes('.') ? reference.external_id.split('.')[0] : null,
url: reference.url ?? `https://attack.mitre.org/techniques/${reference.external_id.replace('.', '/')}`,
sourceCount: item.external_references?.length ?? 0
}];
}).sort(byId);
if (!techniques.length) throw new Error('Enterprise ATT&CK bundle contains no active techniques.');
const entityTypes: Record<string, MitreEntity['type']> = {
'intrusion-set': 'group', campaign: 'campaign', malware: 'software', tool: 'software'
};
const entitiesByStixId = new Map<string, MitreEntity>();
for (const item of objects) {
const type = entityTypes[item.type ?? ''];
const reference = attackReference(item);
if (type && active(item) && item.id && item.name && reference?.external_id) {
entitiesByStixId.set(item.id, { id: reference.external_id, name: item.name, type, techniques: [] });
}
}
for (const item of objects) {
if (item.type !== 'relationship' || item.relationship_type !== 'uses' || !active(item)) continue;
const entity = entitiesByStixId.get(item.source_ref ?? '');
const techniqueId = techniqueStixIds.get(item.target_ref ?? '');
if (entity && techniqueId && !entity.techniques.includes(techniqueId)) entity.techniques.push(techniqueId);
}
const entities = [...entitiesByStixId.values()].sort(byId).map(entity => ({
...entity, techniques: entity.techniques.sort()
}));
return { version: collection.x_mitre_version, tacticOrder, techniques, entities };
}

View File

@ -15,7 +15,9 @@ import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadi
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog'; import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary'; import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
import { MitreMatrixLibrary } from './MitreMatrixLibrary'; import { MitreMatrixLibrary } from './MitreMatrixLibrary';
import { clearStoredMitreMatrix, loadStoredMitreMatrix, saveStoredMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph'; import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
import { downloadBrowserBlob } from '../../platform/browser/browser-download-adapter';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
import { DatahubHistoryOverview, type DatahubTimeslotSelection } from './DatahubHistoryOverview'; import { DatahubHistoryOverview, type DatahubTimeslotSelection } from './DatahubHistoryOverview';
import { comparableTimeslotSources, type DatahubTimeslotResultMetrics } from './datahub-scan-analysis'; import { comparableTimeslotSources, type DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
@ -24,11 +26,12 @@ import { summarizeScanProcessingReadiness } from './datahub-processing-readiness
import { compareReimportSourceHashes } from '../../domain/reimport-source'; import { compareReimportSourceHashes } from '../../domain/reimport-source';
import { loadTimeslotOpenReadiness, type TimeslotOpenReadiness } from './datahub-open-readiness'; import { loadTimeslotOpenReadiness, type TimeslotOpenReadiness } from './datahub-open-readiness';
import { DatahubMetricsCache } from './datahub-metrics-cache'; import { DatahubMetricsCache } from './datahub-metrics-cache';
import { useReferenceRecognitionStatus } from './use-reference-recognition-status';
import { datahubScanLabel } from './datahub-scan-labels';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
const metricsCache = new DatahubMetricsCache(scope => assessorApi.getTimeslotResultMetrics( const metricsCache = new DatahubMetricsCache(scope => assessorApi.getTimeslotResultMetrics(
scope.projectId, scope.scanRunIds, scope.assessmentIds)); scope.projectId, scope.scanRunIds, scope.assessmentIds));
const SHOW_MITRE_MATRIX_LIBRARY = false;
type BackendStatus = 'connected' | 'checking' | 'unavailable'; type BackendStatus = 'connected' | 'checking' | 'unavailable';
interface DataHubProps { interface DataHubProps {
@ -84,7 +87,8 @@ function groupScanReports(files: ImportFileRecord[], scanRuns: ScanRun[] = []):
scanRunId, scanRunId,
scanRunIds: [scanRunId], scanRunIds: [scanRunId],
assessmentIds: run ? [run.assessmentId] : [], assessmentIds: run ? [run.assessmentId] : [],
tool: run?.tool ?? runFiles[0].detectedTool, tool: { ...(run?.tool ?? runFiles[0].detectedTool),
name: datahubScanLabel(run?.tool ?? runFiles[0].detectedTool, scanRunId, runFiles) },
files: runFiles, files: runFiles,
gpoBackups: [], gpoBackups: [],
sourceCount: 1, sourceCount: 1,
@ -100,6 +104,9 @@ function groupScanReports(files: ImportFileRecord[], scanRuns: ScanRun[] = []):
} }
export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChanged, onDetailViewChanged, backendStatus }: DataHubProps) { export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChanged, onDetailViewChanged, backendStatus }: DataHubProps) {
const [projects, setProjects] = useState<AssessmentProject[]>([]); const [projects, setProjects] = useState<AssessmentProject[]>([]);
const [referenceRefreshKey, setReferenceRefreshKey] = useState(0);
const { states: projectReferenceReadiness, reportStatus: reportReferenceStatus } = useReferenceRecognitionStatus(
projects, backendStatus === 'connected', referenceRefreshKey);
const [projectData, setProjectData] = useState<Record<string, ImportFileRecord[]>>({}); const [projectData, setProjectData] = useState<Record<string, ImportFileRecord[]>>({});
const [projectHistory, setProjectHistory] = useState<Record<string, ProjectAssessmentHistoryResponse>>({}); const [projectHistory, setProjectHistory] = useState<Record<string, ProjectAssessmentHistoryResponse>>({});
const [projectGpoData, setProjectGpoData] = useState<Record<string, GpoBackupStatusRecord[]>>({}); const [projectGpoData, setProjectGpoData] = useState<Record<string, GpoBackupStatusRecord[]>>({});
@ -116,6 +123,8 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saveFeedback, setSaveFeedback] = useState<SaveFeedback | null>(null); const [saveFeedback, setSaveFeedback] = useState<SaveFeedback | null>(null);
const [mitreStatus, setMitreStatus] = useState(() => loadStoredMitreMatrix()); const [mitreStatus, setMitreStatus] = useState(() => loadStoredMitreMatrix());
const [mitreActiveVersion, setMitreActiveVersion] = useState(() => activeMitreMatrix().version);
const [mitreBusy, setMitreBusy] = useState<'refresh' | 'download' | 'upload' | null>(null);
const [mitreError, setMitreError] = useState<string | null>(null); const [mitreError, setMitreError] = useState<string | null>(null);
const [mitreFeedback, setMitreFeedback] = useState<string | null>(null); const [mitreFeedback, setMitreFeedback] = useState<string | null>(null);
const [scanSelection, setScanSelection] = useState<DatahubTimeslotSelection | null>(null); const [scanSelection, setScanSelection] = useState<DatahubTimeslotSelection | null>(null);
@ -129,6 +138,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false); const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false);
const [timeslotDate, setTimeslotDate] = useState(''); const [timeslotDate, setTimeslotDate] = useState('');
const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({}); const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({});
const [scanLabels, setScanLabels] = useState<Record<string, string>>({});
const [projectOpenReadiness, setProjectOpenReadiness] = useState<Record<string, Record<string, TimeslotOpenReadiness>>>({}); const [projectOpenReadiness, setProjectOpenReadiness] = useState<Record<string, Record<string, TimeslotOpenReadiness>>>({});
const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information'); const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information');
const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>()); const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>());
@ -142,11 +152,58 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
useEffect(() => () => onDetailViewChanged?.(false), [onDetailViewChanged]); useEffect(() => () => onDetailViewChanged?.(false), [onDetailViewChanged]);
useEffect(() => {
let mounted = true;
void assessorApi.getMitreCatalog().then(state => {
if (!mounted) return;
setServerMitreMatrix(state.catalog);
setMitreActiveVersion(activeMitreMatrix().version);
}).catch(() => undefined);
return () => { mounted = false; };
}, []);
const handleMitreRefresh = async () => {
setMitreBusy('refresh');
setMitreError(null);
setMitreFeedback(null);
try {
const result = await assessorApi.refreshMitreCatalog();
clearStoredMitreMatrix();
setMitreStatus(null);
setServerMitreMatrix(result.catalog);
setMitreActiveVersion(result.catalog.version);
setReferenceRefreshKey(current => current + 1);
setMitreFeedback(result.updated
? `Enterprise ATT&CK v${result.catalog.version} is active in ASSESSOR.`
: `Enterprise ATT&CK v${result.catalog.version} is already current.`);
} catch (error) {
setMitreError(error instanceof Error ? error.message : 'MITRE could not be updated.');
} finally {
setMitreBusy(null);
}
};
const handleMitreDownload = async () => {
setMitreBusy('download');
setMitreError(null);
setMitreFeedback(null);
try {
const source = await assessorApi.downloadLatestMitreSource();
downloadBrowserBlob(source.blob, source.fileName);
setMitreFeedback(`Official MITRE file ${source.fileName} downloaded.`);
} catch (error) {
setMitreError(error instanceof Error ? error.message : 'MITRE could not be downloaded.');
} finally {
setMitreBusy(null);
}
};
const handleMitreUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleMitreUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setMitreError(null); setMitreError(null);
setMitreFeedback(null); setMitreFeedback(null);
setMitreBusy('upload');
setOperationProgress('Importing MITRE Matrix'); setOperationProgress('Importing MITRE Matrix');
try { try {
const text = await file.text(); const text = await file.text();
@ -154,12 +211,14 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const validated = validateMitreMatrixData(parsed); const validated = validateMitreMatrixData(parsed);
saveStoredMitreMatrix(validated); saveStoredMitreMatrix(validated);
setMitreStatus(validated); setMitreStatus(validated);
setMitreActiveVersion(validated.version);
setMitreFeedback(`MITRE Matrix version ${validated.version} (${validated.techniques.length} techniques, ${validated.tacticOrder.length} tactics) updated successfully.`); setMitreFeedback(`MITRE Matrix version ${validated.version} (${validated.techniques.length} techniques, ${validated.tacticOrder.length} tactics) updated successfully.`);
globalThis.dispatchEvent(new CustomEvent('assessor:mitre-matrix-changed')); globalThis.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
} catch (err) { } catch (err) {
setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.'); setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.');
} finally { } finally {
setOperationProgress(undefined); setOperationProgress(undefined);
setMitreBusy(null);
event.target.value = ''; event.target.value = '';
} }
}; };
@ -167,9 +226,10 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const handleMitreReset = () => { const handleMitreReset = () => {
clearStoredMitreMatrix(); clearStoredMitreMatrix();
setMitreStatus(null); setMitreStatus(null);
setMitreFeedback('Default matrix v18.1 restored.'); setMitreActiveVersion(activeMitreMatrix().version);
setMitreFeedback(`Active catalog v${activeMitreMatrix().version} restored.`);
setMitreError(null); setMitreError(null);
globalThis.dispatchEvent(new CustomEvent('assessor:mitre-matrix-changed')); globalThis.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
}; };
const loadProjects = useCallback(async () => { const loadProjects = useCallback(async () => {
@ -208,6 +268,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
setProjectData(Object.fromEntries(data.map(item => [item.projectId, item.files]))); setProjectData(Object.fromEntries(data.map(item => [item.projectId, item.files])));
setProjectHistory(Object.fromEntries(data.map(item => [item.projectId, item.history]))); setProjectHistory(Object.fromEntries(data.map(item => [item.projectId, item.history])));
setProjectGpoData(Object.fromEntries(data.map(item => [item.projectId, item.gpoRecords]))); setProjectGpoData(Object.fromEntries(data.map(item => [item.projectId, item.gpoRecords])));
setReferenceRefreshKey(current => current + 1);
setMetricsRevision(health.repositoryRevision); setMetricsRevision(health.repositoryRevision);
// Open-readiness checks are independent of the six result metrics. Start // Open-readiness checks are independent of the six result metrics. Start
// them after the overview is available so they cannot hold back the rings. // them after the overview is available so they cannot hold back the rings.
@ -373,12 +434,16 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const projectId = form?.project?.id; const projectId = form?.project?.id;
if (!projectId || editedReports.length === 0) { if (!projectId || editedReports.length === 0) {
setScanReadiness({}); setScanReadiness({});
setScanLabels({});
return; return;
} }
let cancelled = false; let cancelled = false;
setScanLabels({});
setScanReadiness(Object.fromEntries(editedReports.map(report => [report.scanRunId, 'checking']))); setScanReadiness(Object.fromEntries(editedReports.map(report => [report.scanRunId, 'checking'])));
void loadProjectModels(projectId).then(models => { void loadProjectModels(projectId).then(models => {
if (cancelled) return; if (cancelled) return;
setScanLabels(Object.fromEntries(editedReports.map(report => [report.scanRunId,
datahubScanLabel(report.tool, report.scanRunId, report.files, models)])));
setScanReadiness(Object.fromEntries(editedReports.map(report => [ setScanReadiness(Object.fromEntries(editedReports.map(report => [
report.scanRunId, report.scanRunId,
datahubScanReadiness(models, report.scanRunId, report.tool.id) datahubScanReadiness(models, report.scanRunId, report.tool.id)
@ -788,7 +853,10 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
</summary> </summary>
{reports.length === 0 && group.gpoBackups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No sources</h3><p>This timeslot does not contain a scan report or GPO backup yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Assessment sources from ${formatTenantDate(group.fileDate)}`}> {reports.length === 0 && group.gpoBackups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No sources</h3><p>This timeslot does not contain a scan report or GPO backup yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Assessment sources from ${formatTenantDate(group.fileDate)}`}>
<div className="tenant-report-row tenant-report-head" role="row"><span aria-hidden="true"/><span>Source</span><span>Imported</span><span>Status</span><span>Actions</span></div> <div className="tenant-report-row tenant-report-head" role="row"><span aria-hidden="true"/><span>Source</span><span>Imported</span><span>Status</span><span>Actions</span></div>
{reports.map(report => { {reports.map(sourceReport => {
const report = scanLabels[sourceReport.scanRunId]
? { ...sourceReport, tool: { ...sourceReport.tool, name: scanLabels[sourceReport.scanRunId] } }
: sourceReport;
const readiness = scanReadiness[report.scanRunId] ?? 'checking'; const readiness = scanReadiness[report.scanRunId] ?? 'checking';
const processing = processingByRun.get(report.scanRunId)!; const processing = processingByRun.get(report.scanRunId)!;
const viewable = isDatahubScanViewable(readiness); const viewable = isDatahubScanViewable(readiness);
@ -862,7 +930,12 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
{form.project && <button className="btn primary tenant-navigation-open" type="button" disabled={openDisabled} title={!selectedTimeslots.length ? 'Select at least one timeslot' : selectedTimeslots.length > 1 ? 'Open comparable sources from the selected timeslots' : 'Open selected scan reports'} onClick={openSelectedTimeslots}>Open</button>} {form.project && <button className="btn primary tenant-navigation-open" type="button" disabled={openDisabled} title={!selectedTimeslots.length ? 'Select at least one timeslot' : selectedTimeslots.length > 1 ? 'Open comparable sources from the selected timeslots' : 'Open selected scan reports'} onClick={openSelectedTimeslots}>Open</button>}
<div className="tenant-identity"><h2>{form.mode === 'create' ? 'Create Client' : form.project?.name}</h2>{form.project && <p>{formatTenantDate(form.project.createdAt)}</p>}</div> <div className="tenant-identity"><h2>{form.mode === 'create' ? 'Create Client' : form.project?.name}</h2>{form.project && <p>{formatTenantDate(form.project.createdAt)}</p>}</div>
</section> </section>
{form.mode === 'edit' && filesSection} {form.mode === 'edit' && <div className="tenant-source-navigation">
{form.project && <ReferenceRecognitionPanel key={form.project.id} projectId={form.project.id}
onStatusChanged={reportReferenceStatus}
onChanged={() => onProjectDataChanged?.(form.project!.id)}/>}
{filesSection}
</div>}
</aside> </aside>
<main className="tenant-detail-information"> <main className="tenant-detail-information">
{detailSection === 'client-information' ? <section className="tenant-information-sheet" aria-label="Client information sheet"> {detailSection === 'client-information' ? <section className="tenant-information-sheet" aria-label="Client information sheet">
@ -893,6 +966,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
projectHistory={projectHistory} projectHistory={projectHistory}
projectGpoData={projectGpoData} projectGpoData={projectGpoData}
projectOpenReadiness={projectOpenReadiness} projectOpenReadiness={projectOpenReadiness}
projectReferenceReadiness={projectReferenceReadiness}
selection={scanSelection} selection={scanSelection}
timeslotMetrics={timeslotMetrics} timeslotMetrics={timeslotMetrics}
loading={loading} loading={loading}
@ -903,15 +977,19 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
onOpen={(project, scanRunIds, assessmentIds) => onProjectSelected(project, scanRunIds, assessmentIds)} onOpen={(project, scanRunIds, assessmentIds) => onProjectSelected(project, scanRunIds, assessmentIds)}
onSelectProject={selectProject} onSelectProject={selectProject}
/> />
<div className={`datahub-reference-row${SHOW_MITRE_MATRIX_LIBRARY ? '' : ' is-mitre-hidden'}`}> <div className="datahub-reference-row">
<MicrosoftBaselineLibrary/> <MicrosoftBaselineLibrary/>
{SHOW_MITRE_MATRIX_LIBRARY && <MitreMatrixLibrary <MitreMatrixLibrary
status={mitreStatus} status={mitreStatus}
activeVersion={mitreActiveVersion}
busy={mitreBusy}
feedback={mitreFeedback} feedback={mitreFeedback}
error={mitreError} error={mitreError}
onRefresh={() => void handleMitreRefresh()}
onDownload={() => void handleMitreDownload()}
onUpload={event => void handleMitreUpload(event)} onUpload={event => void handleMitreUpload(event)}
onReset={handleMitreReset} onReset={handleMitreReset}
/>} />
</div> </div>
</>} </>}
{scanDeleteTarget && <Dialog title="Delete scan report from the database?" onClose={() => !busy && setScanDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{scanDeleteTarget.tool.name}</strong><p>{new Date(scanDeleteTarget.fileDate).toLocaleString('en-US')} · {scanDeleteTarget.fileCount} file{scanDeleteTarget.fileCount === 1 ? '' : 's'}</p><p>The scan report, its findings, evidence, and scan-related mappings will be permanently deleted. Other scan reports for this client remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setScanDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteScanRun()}>{busy ? 'Deleting …' : 'Yes, delete scan report'}</button></footer></div></Dialog>} {scanDeleteTarget && <Dialog title="Delete scan report from the database?" onClose={() => !busy && setScanDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{scanDeleteTarget.tool.name}</strong><p>{new Date(scanDeleteTarget.fileDate).toLocaleString('en-US')} · {scanDeleteTarget.fileCount} file{scanDeleteTarget.fileCount === 1 ? '' : 's'}</p><p>The scan report, its findings, evidence, and scan-related mappings will be permanently deleted. Other scan reports for this client remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setScanDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteScanRun()}>{busy ? 'Deleting …' : 'Yes, delete scan report'}</button></footer></div></Dialog>}

View File

@ -1,7 +1,7 @@
import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract'; import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract';
import type { AssessmentProject, ImportFileRecord } from '../../domain/canonical-model'; import type { AssessmentProject, ImportFileRecord } from '../../domain/canonical-model';
import { groupProjectImportFiles } from './datahub-import-groups'; import { groupProjectImportFiles } from './datahub-import-groups';
import { summarizeClientProcessingReadiness } from './datahub-processing-readiness'; import { summarizeClientProcessingReadiness, withReferenceReadiness, type DatahubReferenceReadiness } from './datahub-processing-readiness';
import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis'; import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard'; import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
import type { TimeslotOpenReadiness } from './datahub-open-readiness'; import type { TimeslotOpenReadiness } from './datahub-open-readiness';
@ -18,6 +18,7 @@ interface DatahubHistoryOverviewProps {
projectHistory: Record<string, ProjectAssessmentHistoryResponse>; projectHistory: Record<string, ProjectAssessmentHistoryResponse>;
projectGpoData: Record<string, GpoBackupStatusRecord[]>; projectGpoData: Record<string, GpoBackupStatusRecord[]>;
projectOpenReadiness?: Record<string, Record<string, TimeslotOpenReadiness>>; projectOpenReadiness?: Record<string, Record<string, TimeslotOpenReadiness>>;
projectReferenceReadiness?: Record<string, DatahubReferenceReadiness>;
selection: DatahubTimeslotSelection | null; selection: DatahubTimeslotSelection | null;
timeslotMetrics: DatahubTimeslotResultMetrics | null; timeslotMetrics: DatahubTimeslotResultMetrics | null;
loading: boolean; loading: boolean;
@ -44,6 +45,7 @@ export function DatahubHistoryOverview({
projectHistory, projectHistory,
projectGpoData, projectGpoData,
projectOpenReadiness = {}, projectOpenReadiness = {},
projectReferenceReadiness = {},
selection, selection,
timeslotMetrics, timeslotMetrics,
loading, loading,
@ -85,11 +87,11 @@ export function DatahubHistoryOverview({
const latest = groups[0]; const latest = groups[0];
const latestOpenable = groups.find(group => projectOpenReadiness[project.id]?.[group.id]?.canOpen); const latestOpenable = groups.find(group => projectOpenReadiness[project.id]?.[group.id]?.canOpen);
const activeProcessingRunIds = groups.flatMap(group => group.scanRunIds); const activeProcessingRunIds = groups.flatMap(group => group.scanRunIds);
const processingSummary = summarizeClientProcessingReadiness( const processingSummary = withReferenceReadiness(summarizeClientProcessingReadiness(
projectData[project.id] ?? [], projectData[project.id] ?? [],
activeProcessingRunIds, activeProcessingRunIds,
projectGpoData[project.id] ?? [] projectGpoData[project.id] ?? []
); ), projectReferenceReadiness[project.id]);
const selected = selection?.projectId === project.id; const selected = selection?.projectId === project.id;
return <article className={`donut-metric-card donut-metric-card--all datahub-tenant-card${selected ? ' is-selected' : ''}`} key={project.id}> return <article className={`donut-metric-card donut-metric-card--all datahub-tenant-card${selected ? ' is-selected' : ''}`} key={project.id}>
{processingSummary.state !== 'no-data' && <span {processingSummary.state !== 'no-data' && <span

View File

@ -2,8 +2,12 @@ import type { MitreGraphData } from '../mitre-graph/mitre-graph';
interface MitreMatrixLibraryProps { interface MitreMatrixLibraryProps {
status: MitreGraphData | null; status: MitreGraphData | null;
activeVersion: string;
busy: 'refresh' | 'download' | 'upload' | null;
feedback: string | null; feedback: string | null;
error: string | null; error: string | null;
onRefresh(): void;
onDownload(): void;
onUpload(event: React.ChangeEvent<HTMLInputElement>): void; onUpload(event: React.ChangeEvent<HTMLInputElement>): void;
onReset(): void; onReset(): void;
} }
@ -14,21 +18,32 @@ interface MitreMatrixLibraryProps {
* Wiederherstellung der Standardmatrix erscheint nur, wenn eine eigene Matrix * Wiederherstellung der Standardmatrix erscheint nur, wenn eine eigene Matrix
* geladen ist - ohne sie gaebe es keinen Weg zurueck. * geladen ist - ohne sie gaebe es keinen Weg zurueck.
*/ */
export function MitreMatrixLibrary({ status, feedback, error, onUpload, onReset }: MitreMatrixLibraryProps) { export function MitreMatrixLibrary({ status, activeVersion, busy, feedback, error, onRefresh, onDownload, onUpload, onReset }: MitreMatrixLibraryProps) {
return <section className="datahub-rack-module datahub-reference-module mitre-library"> return <section className="datahub-rack-module datahub-reference-module mitre-library">
<header className="datahub-rack-header"> <header className="datahub-rack-header">
<span className="datahub-grip" aria-hidden="true">⠿</span> <span className="datahub-grip" aria-hidden="true">⠿</span>
<i className="datahub-led" aria-hidden="true"/> <i className="datahub-led" aria-hidden="true"/>
<strong>MITRE ATT&amp;CK MATRIX</strong> <strong>MITRE ATT&amp;CK MATRIX</strong>
<span className="datahub-rack-badge">{status ? `Custom ${status.version}` : 'v18.1 Standard'}</span> <span className="datahub-rack-badge">{status ? `Custom v${status.version}` : `v${activeVersion} Active`}</span>
</header> </header>
<div className="datahub-rack-body"> <div className="datahub-rack-body">
<div className="datahub-rack-actions"> <div className="datahub-rack-actions">
<label className="btn datahub-maintenance-action datahub-mitre-upload-btn"> <button className="btn datahub-maintenance-action" type="button" disabled={busy !== null} onClick={onRefresh}>
<span>Update Matrix (JSON)</span> {busy === 'refresh' ? 'Checking MITRE…' : 'Update from MITRE'}
<input type="file" accept=".json" onChange={onUpload} className="datahub-mitre-file-input"/> </button>
<button className="btn" type="button" disabled={busy !== null} onClick={onDownload}>
{busy === 'download' ? 'Downloading…' : 'Download from MITRE'}
</button>
<label className="btn datahub-mitre-upload-btn" role="button" tabIndex={busy === null ? 0 : -1} aria-disabled={busy !== null}
onKeyDown={event => {
if (busy !== null || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
event.currentTarget.querySelector('input')?.click();
}}>
<span>{busy === 'upload' ? 'Importing…' : 'Import JSON file'}</span>
<input type="file" accept=".json" disabled={busy !== null} onChange={onUpload} className="datahub-mitre-file-input"/>
</label> </label>
{status && <button className="btn" type="button" onClick={onReset}>Standard wiederherstellen</button>} {status && <button className="btn" type="button" disabled={busy !== null} onClick={onReset}>Use active catalog</button>}
</div> </div>
</div> </div>
{feedback && <div className="tenant-save-feedback success" role="status"><span aria-hidden="true">✓</span><strong>{feedback}</strong></div>} {feedback && <div className="tenant-save-feedback success" role="status"><span aria-hidden="true">✓</span><strong>{feedback}</strong></div>}

View File

@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { ReferenceRecognitionStatus } from '../../api/assessor-api-contract';
import type { DatahubReferenceReadiness } from './datahub-processing-readiness';
const api = new AssessorApiClient();
export function ReferenceRecognitionPanel({ projectId, onChanged, onStatusChanged }: {
projectId: string;
onChanged?(): void | Promise<void>;
onStatusChanged?(projectId: string, state: DatahubReferenceReadiness): void;
}) {
const [status, setStatus] = useState<ReferenceRecognitionStatus>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [updated, setUpdated] = useState(false);
const [failure, setFailure] = useState<'status' | 'update' | 'workspace'>();
const [retryKey, setRetryKey] = useState(0);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
setStatus(undefined); setError(''); setUpdated(false); setFailure(undefined); setExpanded(false);
onStatusChanged?.(projectId, { state: 'checking' });
api.getReferenceRecognitionStatus(projectId).then(value => {
if (!cancelled) {
setStatus(value);
if (value.pendingScans > 0) setExpanded(true);
onStatusChanged?.(projectId, { state: 'ready', status: value });
}
}).catch(reason => {
if (!cancelled) {
setError(reason instanceof Error ? reason.message : 'Reference status unavailable.');
setFailure('status');
setExpanded(true);
onStatusChanged?.(projectId, { state: 'unavailable' });
}
});
return () => { cancelled = true; };
}, [projectId, onStatusChanged, retryKey]);
const recognize = async () => {
setBusy(true); setError(''); setUpdated(false); setFailure(undefined); setExpanded(true);
onStatusChanged?.(projectId, { state: 'processing' });
let processed = false;
let refreshed = false;
try {
await api.recognizeReferences(projectId);
processed = true;
const latestStatus = await api.getReferenceRecognitionStatus(projectId);
setStatus(latestStatus);
if (latestStatus.pendingScans > 0) setExpanded(true);
onStatusChanged?.(projectId, { state: 'ready', status: latestStatus });
refreshed = true;
setUpdated(latestStatus.pendingScans === 0);
await onChanged?.();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Reference recognition failed.');
setFailure(!processed ? 'update' : !refreshed ? 'status' : 'workspace');
setExpanded(true);
if (!refreshed) onStatusChanged?.(projectId, { state: processed ? 'unavailable' : 'error' });
} finally { setBusy(false); }
};
const needsUpdate = Boolean(status?.pendingScans);
const statusLabel = busy ? 'Updating references …'
: failure === 'status' ? 'Status unavailable — please retry'
: failure === 'update' ? 'Update failed — please retry'
: failure === 'workspace' ? 'References updated — reopen the client'
: !status ? 'Checking status …'
: status.totalScans === 0 ? 'No scans to update'
: needsUpdate ? 'Please update'
: updated ? 'Update successful' : 'Up to date';
const nextStep = busy || failure || !status ? undefined
: status.totalScans === 0 ? 'Import a scan first.'
: needsUpdate ? `${status.pendingScans} ${status.pendingScans === 1 ? 'scan needs' : 'scans need'} a reference update.`
: 'No action needed.';
const retryStatus = failure === 'status';
const indicator = busy ? 'neutral' : failure || needsUpdate ? 'attention'
: status && status.totalScans > 0 ? 'ready' : 'neutral';
return <section className="reference-recognition" aria-label="Reference recognition">
<details className="reference-recognition-disclosure" open={expanded}
onToggle={event => setExpanded(event.currentTarget.open)}>
<summary aria-label={`Catalog references: ${statusLabel}`} title={statusLabel}>
<h4><span className="reference-recognition-dot" data-state={indicator} aria-hidden="true"/>Catalog references
<span className="reference-recognition-chevron" aria-hidden="true">{expanded ? '▾' : '▸'}</span>
</h4>
</summary>
<p className="reference-recognition-state" role="status"><strong><span className="reference-recognition-dot" data-state={indicator} aria-hidden="true"/>{statusLabel}</strong>{nextStep && <span>{nextStep}</span>}</p>
<button className="btn primary reference-recognition-action" data-attention={needsUpdate || failure === 'update' || retryStatus}
type="button" disabled={busy || (!retryStatus && (!status || status.totalScans === 0))}
onClick={() => retryStatus ? setRetryKey(current => current + 1) : void recognize()}>
{busy ? 'Updating …' : retryStatus ? 'Retry status' : failure === 'update' ? 'Retry update' : 'Update references'}
</button>
{error && <p role="alert">{error}</p>}
</details>
{status && <p className="reference-recognition-versions">{status.catalogs.map(catalog => `${catalog.framework === 'mitre-attack' ? 'ATT&CK' : 'D3FEND'} ${catalog.version}`).join(' · ')}</p>}
</section>;
}

View File

@ -7,6 +7,7 @@ import { groupProjectImportFiles } from './datahub-import-groups';
import { canonicalSecurityScores } from '../../scoring/canonical-security-score'; import { canonicalSecurityScores } from '../../scoring/canonical-security-score';
import { defaultSecurityScoringPolicy } from '../../domain/security-scoring'; import { defaultSecurityScoringPolicy } from '../../domain/security-scoring';
import { preferredPingCastleReportTitles, preferredPingCastleTitleForOccurrence } from '../../profiles/pingcastle/pingcastle-title-projection'; import { preferredPingCastleReportTitles, preferredPingCastleTitleForOccurrence } from '../../profiles/pingcastle/pingcastle-title-projection';
import { cisBenchmarkSectionTitles } from '../../profiles/cis-cat/cis-benchmark-section-titles';
function text(value: JsonValue): string { function text(value: JsonValue): string {
if (value === null) return ''; if (value === null) return '';
@ -322,6 +323,8 @@ export function canonicalModelsToDataset(
const files = models.flatMap(model => model.importFiles); const files = models.flatMap(model => model.importFiles);
const tables = models.flatMap(model => model.sourceTables); const tables = models.flatMap(model => model.sourceTables);
const occurrences = models.flatMap(model => model.findingOccurrences); const occurrences = models.flatMap(model => model.findingOccurrences);
const referenceByOccurrence = new Map(models.flatMap(model => model.referenceRuns ?? []).flatMap(run => run.results.map(result =>
[result.occurrenceId, { runId: run.id, recognizerVersion: run.recognizerVersion, createdAt: run.createdAt, references: result.references }] as const)));
if (!runs.length || (!occurrences.length && !(models.some(model => model.environmentObservations?.length)))) return null; if (!runs.length || (!occurrences.length && !(models.some(model => model.environmentObservations?.length)))) return null;
const fileById = new Map(files.map(file => [file.id, file])); const fileById = new Map(files.map(file => [file.id, file]));
@ -430,6 +433,11 @@ export function canonicalModelsToDataset(
makeSheet(aggregateName, aggregateRows, rowsToCsv(aggregateRows), project.name, { isProjectSheet: true }), makeSheet(aggregateName, aggregateRows, rowsToCsv(aggregateRows), project.name, { isProjectSheet: true }),
occurrences occurrences
); );
for (const sheet of [aggregate, ...tableSheets]) {
for (const record of sheet.analysis.records) {
if (record.findingOccurrenceId) record.referenceRecognition = referenceByOccurrence.get(record.findingOccurrenceId);
}
}
if (!occurrences.length) aggregate.analysis.records = []; if (!occurrences.length) aggregate.analysis.records = [];
const lastModified = Math.max(...files.map(file => Date.parse(file.lastModifiedAt ?? file.importedAt)), 0); const lastModified = Math.max(...files.map(file => Date.parse(file.lastModifiedAt ?? file.importedAt)), 0);
const sectionSheets = options.summaryOnly ? [] : linkedSectionSheets(occurrences); const sectionSheets = options.summaryOnly ? [] : linkedSectionSheets(occurrences);
@ -441,6 +449,12 @@ export function canonicalModelsToDataset(
)) ))
: []; : [];
const profileVersions = new Set(files.map(file => file.processedWith.profileVersion).filter(Boolean)); const profileVersions = new Set(files.map(file => file.processedWith.profileVersion).filter(Boolean));
// Each record reads section titles only from its own run's report; the map is shared, not copied.
const benchmarkSectionTitles = cisBenchmarkSectionTitles(models.flatMap(model => model.reportSections ?? []));
if (benchmarkSectionTitles.size) for (const sheet of [aggregate, ...tableSheets]) for (const record of sheet.analysis.records) {
const titles = record.scanRunId ? benchmarkSectionTitles.get(record.scanRunId) : undefined;
if (titles?.size) record.benchmarkSectionTitles = titles;
}
return { return {
fileName: project.name + ' · All Scans', fileName: project.name + ' · All Scans',
@ -452,6 +466,7 @@ export function canonicalModelsToDataset(
profileId: tool.id === 'unknown' || tool.id === 'multiple' ? 'generic' : tool.id, profileId: tool.id === 'unknown' || tool.id === 'multiple' ? 'generic' : tool.id,
profileVersion: profileVersions.size === 1 ? [...profileVersions][0] : profileVersions.size > 1 ? 'gemischt' : undefined, profileVersion: profileVersions.size === 1 ? [...profileVersions][0] : profileVersions.size > 1 ? 'gemischt' : undefined,
scanTool: tool.name, scanTool: tool.name,
benchmarkSectionTitles,
securityScoring: { policy: models[0].assessment.scoringPolicy ?? defaultSecurityScoringPolicy(), securityScoring: { policy: models[0].assessment.scoringPolicy ?? defaultSecurityScoringPolicy(),
scans: options.summaryOnly ? [] : canonicalSecurityScores(models) } scans: options.summaryOnly ? [] : canonicalSecurityScores(models) }
}; };

View File

@ -1,14 +1,45 @@
import type { ImportFileRecord } from '../../domain/canonical-model'; import type { ImportFileRecord } from '../../domain/canonical-model';
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract'; import type { GpoBackupStatusRecord, ReferenceRecognitionStatus } from '../../api/assessor-api-contract';
import { currentProcessingManifest, processingReadiness, type ProcessingReadinessStatus } from '../../domain/processing-compatibility'; import { currentProcessingManifest, processingReadiness, type ProcessingReadinessStatus } from '../../domain/processing-compatibility';
export type DatahubClientProcessingState = 'ready' | 'processing' | 'update' | 'review' | 'error' | 'no-data'; export type DatahubClientProcessingState = 'ready' | 'checking' | 'processing' | 'update' | 'review' | 'error' | 'no-data';
export type DatahubReferenceReadiness = { state: 'ready'; status: ReferenceRecognitionStatus }
| { state: 'checking' | 'processing' | 'unavailable' | 'error' };
export interface DatahubClientProcessingSummary { export interface DatahubClientProcessingSummary {
state: DatahubClientProcessingState; state: DatahubClientProcessingState;
label: string; label: string;
} }
/** Reference derivation is independent of scanner reimport and GPO evaluation. */
export function withReferenceReadiness(
processing: DatahubClientProcessingSummary,
references?: DatahubReferenceReadiness
): DatahubClientProcessingSummary {
if (!references) return processing;
let reference: DatahubClientProcessingSummary;
switch (references.state) {
case 'ready':
if (!references.status.pendingScans) return processing.state === 'no-data' && references.status.totalScans > 0
? { state: 'ready', label: 'Processing ready across all client timeslots.' } : processing;
reference = { state: 'update', label: `${references.status.pendingScans} ${references.status.pendingScans === 1 ? 'scan requires' : 'scans require'} reference recognition. Open Manage → Catalog references; no file upload needed.` };
break;
case 'error':
reference = { state: 'error', label: 'Reference recognition failed. Open Manage → Catalog references to retry.' };
break;
case 'unavailable':
reference = { state: 'review', label: 'Reference status unavailable. Open Manage → Catalog references to retry the status check.' };
break;
default:
reference = { state: 'checking', label: references.state === 'processing' ? 'Reference recognition in progress.' : 'Checking reference recognition status.' };
}
const priority: Record<DatahubClientProcessingState, number> = { 'no-data': 0, ready: 0, checking: 1, processing: 2, review: 3, update: 4, error: 5 };
if (priority[processing.state] === 0) return reference;
return { state: priority[processing.state] >= priority[reference.state] ? processing.state : reference.state,
label: `${processing.label} ${reference.label}` };
}
const UPDATE_STATES = new Set(['reimport-recommended']); const UPDATE_STATES = new Set(['reimport-recommended']);
const ERROR_STATES = new Set(['incompatible', 'unknown']); const ERROR_STATES = new Set(['incompatible', 'unknown']);
const ATTENTION_STATES = new Set([...UPDATE_STATES, ...ERROR_STATES]); const ATTENTION_STATES = new Set([...UPDATE_STATES, ...ERROR_STATES]);

View File

@ -0,0 +1,23 @@
import type { CanonicalAssessmentModel, ImportFileRecord, ToolIdentity } from '../../domain/canonical-model';
import { PURPLE_KNIGHT_RUN_NAMES } from '../../profiles/purple-knight/run-names';
/** Display-only scope from retained, run-local facts. Never rewrite stored scanner identities. */
export function datahubScanLabel(
tool: ToolIdentity, scanRunId: string, files: ImportFileRecord[], models: CanonicalAssessmentModel[] = []
): string {
if (tool.id !== 'purple-knight') return tool.name;
const knownNames: string[] = Object.values(PURPLE_KNIGHT_RUN_NAMES);
if (knownNames.includes(tool.name)) return tool.name;
const runFiles = files.filter(file => file.scanRunId === scanRunId && file.detectedTool.id === tool.id);
const fileIds = new Set(runFiles.map(file => file.id));
const names = runFiles.map(file => file.detectedTool.name);
const properties = models.flatMap(model => model.environmentObservations ?? [])
.filter(observation => observation.scanRunId === scanRunId && fileIds.has(observation.importFileId) && observation.tool.id === tool.id)
.map(observation => observation.property);
const ad = names.includes(PURPLE_KNIGHT_RUN_NAMES.ad) || names.includes(PURPLE_KNIGHT_RUN_NAMES.combined)
|| properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name');
const entra = names.includes(PURPLE_KNIGHT_RUN_NAMES.entra) || names.includes(PURPLE_KNIGHT_RUN_NAMES.combined)
|| properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name');
return ad && entra ? PURPLE_KNIGHT_RUN_NAMES.combined : ad ? PURPLE_KNIGHT_RUN_NAMES.ad
: entra ? PURPLE_KNIGHT_RUN_NAMES.entra : tool.name;
}

View File

@ -49,6 +49,7 @@ export function canonicalModelForScanRuns(
const definitionIds = new Set(findingOccurrences.map(occurrence => occurrence.definitionId)); const definitionIds = new Set(findingOccurrences.map(occurrence => occurrence.definitionId));
return { return {
...model, ...model,
referenceRuns: model.referenceRuns?.filter(run => selectedIds.has(run.scanRunId)),
scanRuns, scanRuns,
importFiles, importFiles,
sourceTables, sourceTables,

View File

@ -1,4 +1,5 @@
import type { ImportedDataset } from '../../domain/dataset'; import type { ImportedDataset } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import type { UnifiedResultProjection } from '../../domain/unified-result'; import type { UnifiedResultProjection } from '../../domain/unified-result';
import { POLICY_EVALUATION_ENGINE, type StoredPolicyEvaluation } from '../../domain/policy-evaluation'; import { POLICY_EVALUATION_ENGINE, type StoredPolicyEvaluation } from '../../domain/policy-evaluation';
import { buildPolicyWorkResults } from '../../evaluation/policy-work-results'; import { buildPolicyWorkResults } from '../../evaluation/policy-work-results';
@ -44,6 +45,20 @@ function notComparableSummary(sources: UnifiedResultProjection[]): string {
].filter(Boolean).join('. ') + '.'; ].filter(Boolean).join('. ') + '.';
} }
/**
* A scanner record carried into the rebuilt project sheet. Only what the extended row layout recomputes
* comes from the rebuilt record (identity, position, field values and their indexes); every other
* property - provenance, recognized references and any future record metadata - stays the original's.
*/
export function withRebuiltLayout(original: FindingRecord, rebuilt: FindingRecord): FindingRecord {
const merged: FindingRecord = { ...original, id: rebuilt.id, index: rebuilt.index, title: rebuilt.title,
fields: rebuilt.fields, filled: rebuilt.filled, meta: rebuilt.meta };
if (rebuilt.sourceFile === undefined) delete merged.sourceFile; else merged.sourceFile = rebuilt.sourceFile;
if (rebuilt.sourceSheet === undefined) delete merged.sourceSheet; else merged.sourceSheet = rebuilt.sourceSheet;
if (rebuilt.highlighted === undefined) delete merged.highlighted; else merged.highlighted = rebuilt.highlighted;
return merged;
}
export function appendPolicyObservationsToDataset( export function appendPolicyObservationsToDataset(
dataset: ImportedDataset | null, dataset: ImportedDataset | null,
projectName: string, projectName: string,
@ -209,11 +224,7 @@ export function appendPolicyObservationsToDataset(
}) }); }) });
} }
} }
return original ? { ...record, findingOccurrenceId: original.findingOccurrenceId, return original ? withRebuiltLayout(original, record)
findingDefinitionId: original.findingDefinitionId, scanRunId: original.scanRunId, importFileId: original.importFileId,
policyFailingBackupSources: original.policyFailingBackupSources, policySourceEvidence: original.policySourceEvidence,
policyBaselineLabels: original.policyBaselineLabels, policyBaselineComparisons: original.policyBaselineComparisons,
policyReviewMembers: original.policyReviewMembers }
: { ...record, ...(policyRecord?.policyReviewMembers ? { id: policyRecord.id, policyReviewMembers: policyRecord.policyReviewMembers } : {}), : { ...record, ...(policyRecord?.policyReviewMembers ? { id: policyRecord.id, policyReviewMembers: policyRecord.policyReviewMembers } : {}),
policyFailingBackupSources: policyEntries[index - first.analysis.records.length]?.failingBackupSources, policyFailingBackupSources: policyEntries[index - first.analysis.records.length]?.failingBackupSources,
policySourceEvidence: policyEntries[index - first.analysis.records.length]?.sourceEvidence, policySourceEvidence: policyEntries[index - first.analysis.records.length]?.sourceEvidence,

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { AssessmentProject } from '../../domain/canonical-model';
import type { DatahubReferenceReadiness } from './datahub-processing-readiness';
const api = new AssessorApiClient();
/** Load only status summaries, with bounded concurrency and no background polling. */
export function useReferenceRecognitionStatus(projects: AssessmentProject[], connected: boolean, refreshKey: number) {
const [states, setStates] = useState<Record<string, DatahubReferenceReadiness>>({});
const pending = useRef(new Map<string, symbol>());
const reportStatus = useCallback((projectId: string, state: DatahubReferenceReadiness) => {
// A panel result must win over an older overview request for the same client.
pending.current.delete(projectId);
setStates(current => ({ ...current, [projectId]: state }));
}, []);
useEffect(() => {
let cancelled = false;
let next = 0;
pending.current.clear();
const queue = connected ? projects.map(project => ({ ...project, token: Symbol() })) : [];
for (const project of queue) pending.current.set(project.id, project.token);
setStates(Object.fromEntries(queue.map(project => [project.id, { state: 'checking' }])));
const loadNext = async () => {
while (!cancelled && next < queue.length) {
const { id: projectId, token } = queue[next++];
if (pending.current.get(projectId) !== token) continue;
let state: DatahubReferenceReadiness;
try { state = { state: 'ready', status: await api.getReferenceRecognitionStatus(projectId) }; }
catch { state = { state: 'unavailable' }; }
if (!cancelled && pending.current.get(projectId) === token) {
pending.current.delete(projectId);
setStates(current => ({ ...current, [projectId]: state }));
}
}
};
for (let worker = 0; worker < Math.min(3, queue.length); worker++) void loadNext();
return () => { cancelled = true; };
}, [projects, connected, refreshKey]);
return { states, reportStatus };
}

View File

@ -9,6 +9,7 @@ import { SEVERITY_LABEL, isSeverityHeader, normalizeSeverityValue, severityRank
import { clean, normalizeNumericValue } from '../../shared/text'; import { clean, normalizeNumericValue } from '../../shared/text';
import { sourceFileLabel } from '../../shared/source-file-label'; import { sourceFileLabel } from '../../shared/source-file-label';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisBenchmarkSectionTitles, cisCategoryTitle, compareCisCategories } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache'; import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache';
import { toolIdForRecord } from '../../importers/status-normalization'; import { toolIdForRecord } from '../../importers/status-normalization';
import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, securityDomainIdForRecord, unclassifiedSecurityDomain } from '../dashboard/security-domains'; import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, securityDomainIdForRecord, unclassifiedSecurityDomain } from '../dashboard/security-domains';
@ -482,7 +483,19 @@ function getStaticFacets(
const profileFacet = profileStatusFacet(sheet, profile, sheet.toolId === 'group-policy' const profileFacet = profileStatusFacet(sheet, profile, sheet.toolId === 'group-policy'
|| Boolean(dataset?.sheets?.some(item => item.toolId === 'group-policy'))); || Boolean(dataset?.sheets?.some(item => item.toolId === 'group-policy')));
const cisCategoryValues = sheet.analysis.records.flatMap(record => cisBenchmarkCategories(record, profile.id)); const cisCategoryValues = sheet.analysis.records.flatMap(record => cisBenchmarkCategories(record, profile.id));
const cisCategoryFacetValues = categoricalValues(cisCategoryValues); // Section titles come only from the report of each record's own scan run; the value stays the number.
const sectionTitles = dataset?.benchmarkSectionTitles ?? cisBenchmarkSectionTitles(dataset?.extraction?.reportSections);
const runsByCategory = new Map<string, Set<string>>();
if (sectionTitles.size) sheet.analysis.records.forEach(record => cisBenchmarkCategories(record, profile.id).forEach(category => {
const runs = runsByCategory.get(category) ?? new Set<string>();
runsByCategory.set(category, runs.add(record.scanRunId ?? ''));
}));
const cisCategoryFacetValues = categoricalValues(cisCategoryValues)
.map(item => {
const title = cisCategoryTitle(sectionTitles, item.label, runsByCategory.get(item.label) ?? []);
return title ? { ...item, label: `${item.label} · ${title}` } : item;
})
.sort((left, right) => compareCisCategories(left.label, right.label));
const cisCategories = cisCategoryFacetValues.length const cisCategories = cisCategoryFacetValues.length
? { ? {
id: 'derived:cis-benchmark-category', id: 'derived:cis-benchmark-category',

View File

@ -23,6 +23,7 @@ import { normalizeStatusDimensionsFor } from './status-normalization';
import { defaultSecurityScoringPolicy } from '../domain/security-scoring'; import { defaultSecurityScoringPolicy } from '../domain/security-scoring';
import { processingManifest } from '../domain/processing-compatibility'; import { processingManifest } from '../domain/processing-compatibility';
import { escapeJsonUnicodeNulls } from '../shared/json-text'; import { escapeJsonUnicodeNulls } from '../shared/json-text';
import { PURPLE_KNIGHT_RUN_NAMES } from '../profiles/purple-knight/run-names';
export interface CanonicalImportBatch { export interface CanonicalImportBatch {
sessionId: string; sessionId: string;
@ -86,7 +87,7 @@ function toolFor(entry: ImportPreviewEntry): ToolIdentity {
return { return {
id, id,
name: id === 'purple-knight' && purpleKnightScope name: id === 'purple-knight' && purpleKnightScope
? purpleKnightScope === 'entra' ? 'Purple Knight for Entra ID' : 'Purple Knight for Active Directory' ? purpleKnightScope === 'entra' ? PURPLE_KNIGHT_RUN_NAMES.entra : PURPLE_KNIGHT_RUN_NAMES.ad
: detectedName, : detectedName,
...(typeof detectedVersion === 'string' && detectedVersion.trim() ? { version: detectedVersion.trim() } : {}) ...(typeof detectedVersion === 'string' && detectedVersion.trim() ? { version: detectedVersion.trim() } : {})
}; };
@ -97,9 +98,9 @@ function purpleKnightRunTool(entries: ImportPreviewEntry[], fallback: ToolIdenti
const properties = entries.flatMap(entry => entry.dataset?.extraction?.environmentObservations?.map(item => item.property) ?? []); const properties = entries.flatMap(entry => entry.dataset?.extraction?.environmentObservations?.map(item => item.property) ?? []);
const entra = properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name'); const entra = properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name');
const ad = properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name'); const ad = properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name');
if (entra && ad) return { ...fallback, name: 'Purple Knight for Active Directory and Entra ID' }; if (entra && ad) return { ...fallback, name: PURPLE_KNIGHT_RUN_NAMES.combined };
if (entra) return { ...fallback, name: 'Purple Knight for Entra ID' }; if (entra) return { ...fallback, name: PURPLE_KNIGHT_RUN_NAMES.entra };
if (ad) return { ...fallback, name: 'Purple Knight for Active Directory' }; if (ad) return { ...fallback, name: PURPLE_KNIGHT_RUN_NAMES.ad };
return fallback; return fallback;
} }

View File

@ -94,8 +94,8 @@ export function normalizeStatusFor(toolId: string | undefined, value: string): C
return normalizeStatusDimensionsFor(toolId, value).legacy; return normalizeStatusDimensionsFor(toolId, value).legacy;
} }
const toolIdByName = new Map(toolProfileDefinitions.map(definition => const toolIdByName = new Map(toolProfileDefinitions.flatMap(definition =>
[definition.name.toLocaleLowerCase('en-US'), definition.id])); [definition.name, ...(definition.runNames ?? [])].map(name => [name.toLocaleLowerCase('en-US'), definition.id] as const)));
/** Provenienzspalte der Projektaggregation: "<Werkzeugname> · <Zeitpunkt>". */ /** Provenienzspalte der Projektaggregation: "<Werkzeugname> · <Zeitpunkt>". */
const SCAN_PROVENANCE_LABEL = /^(?:quelle|source):\s*scan$/i; const SCAN_PROVENANCE_LABEL = /^(?:quelle|source):\s*scan$/i;

View File

@ -91,6 +91,7 @@ export interface ImportStagingData {
} }
export interface CanonicalImportCommit { export interface CanonicalImportCommit {
referenceCatalogs?: import('../domain/reference-recognition').ReferenceCatalogSnapshot[];
sessionId: string; sessionId: string;
model: CanonicalAssessmentModel; model: CanonicalAssessmentModel;
fingerprints: ImportFingerprintInput[]; fingerprints: ImportFingerprintInput[];
@ -116,6 +117,8 @@ export interface ImportSession {
} }
export interface ProjectDataExport { export interface ProjectDataExport {
referenceRuns: import('../domain/reference-recognition').ReferenceRecognitionRun[];
referenceCatalogs: import('../domain/reference-recognition').ReferenceCatalogSnapshot[];
version: 1; version: 1;
project: AssessmentProject; project: AssessmentProject;
policyEvaluations: StoredPolicyEvaluation[]; policyEvaluations: StoredPolicyEvaluation[];
@ -183,6 +186,10 @@ export interface ArtifactJobCommitResult {
} }
export interface AssessorRepository { export interface AssessorRepository {
saveReferenceRun(run: import('../domain/reference-recognition').ReferenceRecognitionRun,
catalogs: import('../domain/reference-recognition').ReferenceCatalogSnapshot[]): Promise<void>;
listReferenceRuns(projectId: string): Promise<import('../domain/reference-recognition').ReferenceRecognitionRun[]>;
listReferenceCatalogs(projectId: string): Promise<import('../domain/reference-recognition').ReferenceCatalogSnapshot[]>;
readonly revision: number; readonly revision: number;
listProjects(): Promise<AssessmentProject[]>; listProjects(): Promise<AssessmentProject[]>;

View File

@ -38,6 +38,8 @@ import { processingScopeIssues } from '../domain/processing-compatibility';
import { compareReimportSourceHashes } from '../domain/reimport-source'; import { compareReimportSourceHashes } from '../domain/reimport-source';
import { planReimportReviewTransfer, type ReimportReviewTransferSummary } from '../domain/reimport-review-transfer'; import { planReimportReviewTransfer, type ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
import { canonicalJson } from '../shared/canonical-json'; import { canonicalJson } from '../shared/canonical-json';
import { latestReferenceRuns, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../domain/reference-recognition';
import { validateReferenceRun } from './reference-run-validation';
import { normalizeComparisonText } from '../comparison/comparison-normalization'; import { normalizeComparisonText } from '../comparison/comparison-normalization';
import { validateLocalComparisonRules } from '../comparison/local-mapping-rules'; import { validateLocalComparisonRules } from '../comparison/local-mapping-rules';
import { import {
@ -85,6 +87,8 @@ import {
} from './repository-errors'; } from './repository-errors';
interface LocalRepositoryState { interface LocalRepositoryState {
referenceRuns: ReferenceRecognitionRun[];
referenceCatalogs: ReferenceCatalogSnapshot[];
projects: AssessmentProject[]; projects: AssessmentProject[];
assessments: Assessment[]; assessments: Assessment[];
scanRuns: ScanRun[]; scanRuns: ScanRun[];
@ -117,6 +121,8 @@ interface LocalRepositoryState {
} }
const emptyState = (): LocalRepositoryState => ({ const emptyState = (): LocalRepositoryState => ({
referenceRuns: [],
referenceCatalogs: [],
projects: [], projects: [],
assessments: [], assessments: [],
scanRuns: [], scanRuns: [],
@ -152,6 +158,22 @@ function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T; return JSON.parse(JSON.stringify(value)) as T;
} }
function appendReferenceRun(state: LocalRepositoryState, run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): boolean {
const existing = state.referenceRuns.find(item => item.id === run.id);
if (existing) {
if (canonicalJson(existing) !== canonicalJson(run)) throw new RepositoryConflictError('Referenzläufe sind unveränderlich.');
return false;
}
validateReferenceRun(run, catalogs, assessmentModel(state, run.assessmentId));
for (const catalog of catalogs) {
const stored = state.referenceCatalogs.find(item => item.sha256 === catalog.sha256);
if (stored && canonicalJson(stored) !== canonicalJson(catalog)) throw new RepositoryConflictError('Katalog-Snapshot ist unveränderlich.');
if (!stored) state.referenceCatalogs.push(clone(catalog));
}
state.referenceRuns.push(clone(run));
return true;
}
function byId<T extends { id: string }>(items: T[]): T[] { function byId<T extends { id: string }>(items: T[]): T[] {
return items.slice().sort((left, right) => left.id.localeCompare(right.id)); return items.slice().sort((left, right) => left.id.localeCompare(right.id));
} }
@ -197,6 +219,7 @@ function assessmentModel(state: LocalRepositoryState, assessmentId: string): Can
}); });
return { return {
schemaVersion: 1, schemaVersion: 1,
referenceRuns: clone(latestReferenceRuns(state.referenceRuns.filter(run => runIds.has(run.scanRunId)))),
project: clone(project), project: clone(project),
assessment: clone(assessment), assessment: clone(assessment),
scanRuns: byId(scanRuns).map(clone), scanRuns: byId(scanRuns).map(clone),
@ -336,6 +359,20 @@ export class LocalAssessorRepository implements AssessorRepository {
return assessmentModel(this.state, assessmentId); return assessmentModel(this.state, assessmentId);
} }
async saveReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): Promise<void> {
const next = clone(this.state);
if (appendReferenceRun(next, run, catalogs)) this.replaceState(next);
}
async listReferenceRuns(projectId: string): Promise<ReferenceRecognitionRun[]> {
return clone(this.state.referenceRuns.filter(run => run.projectId === projectId));
}
async listReferenceCatalogs(projectId: string): Promise<ReferenceCatalogSnapshot[]> {
const hashes = new Set(this.state.referenceRuns.filter(run => run.projectId === projectId).flatMap(run => run.catalogHashes));
return clone(this.state.referenceCatalogs.filter(catalog => hashes.has(catalog.sha256)));
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> { async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.'); if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`); if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -426,6 +463,7 @@ export class LocalAssessorRepository implements AssessorRepository {
appendUnique(next.findingOccurrences, model.findingOccurrences, 'Finding-Vorkommen'); appendUnique(next.findingOccurrences, model.findingOccurrences, 'Finding-Vorkommen');
appendUnique(next.evidence, model.evidence, 'Evidenz'); appendUnique(next.evidence, model.evidence, 'Evidenz');
appendUnique(next.diagnostics, model.diagnostics, 'Importdiagnose'); appendUnique(next.diagnostics, model.diagnostics, 'Importdiagnose');
for (const referenceRun of model.referenceRuns ?? []) appendReferenceRun(next, referenceRun, input.referenceCatalogs ?? []);
const importedAt = model.scanRuns[0].importedAt; const importedAt = model.scanRuns[0].importedAt;
input.fingerprints.forEach(entry => { input.fingerprints.forEach(entry => {
if (!/^[a-f0-9]{64}$/.test(entry.sha256)) throw new RepositoryValidationError('SHA-256-Fingerabdruck ist ungültig.'); if (!/^[a-f0-9]{64}$/.test(entry.sha256)) throw new RepositoryValidationError('SHA-256-Fingerabdruck ist ungültig.');
@ -1388,6 +1426,8 @@ export class LocalAssessorRepository implements AssessorRepository {
return clone({ return clone({
version: 1, version: 1,
project, project,
referenceRuns: await this.listReferenceRuns(projectId),
referenceCatalogs: await this.listReferenceCatalogs(projectId),
assessments: byId(assessments), assessments: byId(assessments),
scanRuns: byId(scanRuns), scanRuns: byId(scanRuns),
importFiles: byId(importFiles), importFiles: byId(importFiles),
@ -1445,6 +1485,7 @@ export class LocalAssessorRepository implements AssessorRepository {
const processingRunIds = new Set(next.processingRuns.filter(item => artifactIds.has(item.artifactId)).map(item => item.id)); const processingRunIds = new Set(next.processingRuns.filter(item => artifactIds.has(item.artifactId)).map(item => item.id));
next.scanRuns = next.scanRuns.filter(item => item.id !== scanRunId); next.scanRuns = next.scanRuns.filter(item => item.id !== scanRunId);
next.referenceRuns = next.referenceRuns.filter(item => item.scanRunId !== scanRunId);
next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id));
next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId));
next.reportSections = next.reportSections.filter(item => item.scanRunId !== scanRunId); next.reportSections = next.reportSections.filter(item => item.scanRunId !== scanRunId);
@ -1515,6 +1556,7 @@ export class LocalAssessorRepository implements AssessorRepository {
next.assessments = next.assessments.filter(item => item.id !== assessmentId); next.assessments = next.assessments.filter(item => item.id !== assessmentId);
next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id)); next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id));
next.referenceRuns = next.referenceRuns.filter(item => !runIds.has(item.scanRunId));
next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id));
next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId));
next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId)); next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId));
@ -1563,6 +1605,7 @@ export class LocalAssessorRepository implements AssessorRepository {
next.projects = next.projects.filter(item => item.id !== projectId); next.projects = next.projects.filter(item => item.id !== projectId);
next.assessments = next.assessments.filter(item => !assessmentIds.has(item.id)); next.assessments = next.assessments.filter(item => !assessmentIds.has(item.id));
next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id)); next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id));
next.referenceRuns = next.referenceRuns.filter(item => !runIds.has(item.scanRunId));
next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id));
next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId));
next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId)); next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId));

View File

@ -0,0 +1,29 @@
import type { CanonicalAssessmentModel } from '../domain/canonical-model';
import { createReferenceRecognizer, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../domain/reference-recognition';
import { canonicalJson } from '../shared/canonical-json';
import { RepositoryValidationError } from './repository-errors';
export function validateReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[], model: CanonicalAssessmentModel | null): void {
const hash = /^[a-f0-9]{64}$/;
if (!model || model.project.id !== run.projectId || model.assessment.id !== run.assessmentId
|| !model.scanRuns.some(scan => scan.id === run.scanRunId) || run.schemaVersion !== 1
|| !run.id || !run.recognizerVersion || !hash.test(run.inputSha256) || !Number.isFinite(Date.parse(run.createdAt))
|| catalogs.length !== 2 || new Set(catalogs.map(catalog => catalog.framework)).size !== 2
|| catalogs.some(catalog => !hash.test(catalog.sha256) || !catalog.entries.length || !catalog.license)
|| canonicalJson([...run.catalogHashes].sort()) !== canonicalJson(catalogs.map(catalog => catalog.sha256).sort())) {
throw new RepositoryValidationError('Ungültiger Referenzlauf oder fremder Scan-/Katalogkontext.');
}
const occurrences = model.findingOccurrences.filter(item => item.scanRunId === run.scanRunId);
const results = new Map(run.results.map(result => [result.occurrenceId, result]));
const recognize = createReferenceRecognizer(catalogs);
if (results.size !== occurrences.length || results.size !== run.results.length) throw new RepositoryValidationError('Unvollständiger Referenzlauf.');
for (const occurrence of occurrences) {
const result = results.get(occurrence.id);
const file = model.importFiles.find(item => item.id === occurrence.importFileId);
if (!result || result.importFileId !== occurrence.importFileId || result.sourceFileSha256 !== file?.sha256
|| canonicalJson(result.sourcePosition) !== canonicalJson(occurrence.sourcePosition)
|| canonicalJson(result.references) !== canonicalJson(recognize(occurrence.originalFields))) {
throw new RepositoryValidationError('Referenzherkunft stimmt nicht mit dem gespeicherten Original überein.');
}
}
}

View File

@ -1,9 +1,8 @@
import type { DownloadAdapter } from '../../exports/download-adapter'; import type { DownloadAdapter } from '../../exports/download-adapter';
export const browserDownloadAdapter: DownloadAdapter = { export function downloadBrowserBlob(blob: Blob, fileName: string): void {
download({ fileName, text, mediaType }) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([text], { type: mediaType })); link.href = URL.createObjectURL(blob);
link.download = fileName; link.download = fileName;
link.hidden = true; link.hidden = true;
document.body.appendChild(link); document.body.appendChild(link);
@ -11,4 +10,9 @@ export const browserDownloadAdapter: DownloadAdapter = {
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
link.remove(); link.remove();
} }
export const browserDownloadAdapter: DownloadAdapter = {
download({ fileName, text, mediaType }) {
downloadBrowserBlob(new Blob([text], { type: mediaType }), fileName);
}
}; };

View File

@ -0,0 +1,96 @@
import type { ScannerReportSectionCandidate } from '../../domain/source-extraction';
/** Benchmark section titles per scan run (`''` for a single direct import without a run identity). */
export type CisBenchmarkSectionTitles = Map<string, Map<string, string>>;
const SECTION_SCORES_KEY = 'cis-cat-section-scores';
const NUMBERED_TITLE = /^((?:\d+\.)*\d+)\s+(.+)$/;
/**
* Reads the section titles the CIS-CAT report lists in its section-score table
* (`18.10.42 Microsoft Defender Antivirus ...`). A number with differing titles
* inside one run is left out rather than guessed.
*/
export function cisBenchmarkSectionTitles(
sections: ReadonlyArray<Pick<ScannerReportSectionCandidate, 'sectionKey'> & {
scanRunId?: string; tables: ReadonlyArray<{ headers: string[]; rows: ReadonlyArray<ReadonlyArray<unknown>> }>;
}> | undefined
): CisBenchmarkSectionTitles {
const titles: CisBenchmarkSectionTitles = new Map();
const conflicts = new Map<string, Set<string>>();
for (const section of sections ?? []) {
if (section.sectionKey !== SECTION_SCORES_KEY) continue;
const runId = section.scanRunId ?? '';
const run = titles.get(runId) ?? new Map<string, string>();
const runConflicts = conflicts.get(runId) ?? new Set<string>();
for (const table of section.tables) {
const column = table.headers.findIndex(header => /^description$/i.test(header.trim()));
if (column < 0) continue;
for (const row of table.rows) {
const match = NUMBERED_TITLE.exec(String(row[column] ?? '').trim());
if (!match) continue;
const [, id, title] = match;
const known = run.get(id);
if (known !== undefined && known !== title.trim()) runConflicts.add(id);
else run.set(id, title.trim());
}
}
runConflicts.forEach(id => run.delete(id));
titles.set(runId, run);
conflicts.set(runId, runConflicts);
}
return titles;
}
/**
* Title of a derived `CIS <number>` category for the given records. Only a title that every record
* resolves identically from its own run is returned; otherwise the number stands alone.
*/
export function cisCategoryTitle(
titles: CisBenchmarkSectionTitles | undefined,
category: string,
runIds: Iterable<string>
): string | undefined {
const id = /^CIS\s+((?:\d+\.)*\d+)$/.exec(category.trim())?.[1];
if (!id || !titles?.size) return undefined;
const found = new Set<string>();
for (const runId of runIds) {
const title = titles.get(runId)?.get(id);
if (!title) return undefined;
found.add(title);
}
return found.size === 1 ? [...found][0] : undefined;
}
/** Section title of one derived `CIS <number>` category, from the record's own scan-run report. */
export function cisCategoryRecordTitle(record: { benchmarkSectionTitles?: ReadonlyMap<string, string> }, category: string): string | undefined {
const id = /^CIS\s+((?:\d+\.)*\d+)$/.exec(category.trim())?.[1];
return id ? record.benchmarkSectionTitles?.get(id) : undefined;
}
/**
* Heading of a CIS finding with its leading rule number replaced by the title of the section that
* contains the rule, from the record's own run report (`2.2.2 Ensure ...` -> `User Rights Assignment:
* Ensure ...`). The exact number stays in the report's Source Rule ID; without a title the heading is unchanged.
*/
export function cisSectionHeading(
record: { benchmarkSectionTitles?: ReadonlyMap<string, string> }, ruleId: string, heading: string
): string {
const section = ruleId.split('.').slice(0, -1).join('.');
const title = section ? record.benchmarkSectionTitles?.get(section) : undefined;
const rest = heading.startsWith(ruleId) ? heading.slice(ruleId.length) : '';
return title && /^\s+\S/.test(rest) ? `${title}: ${rest.trim()}` : heading;
}
/** Numeric order of `CIS <number>` categories: 2, 2.3, 2.3.10, 17, 18, 18.6, 18.10. */
export function compareCisCategories(left: string, right: string): number {
const parts = (value: string) => (/((?:\d+\.)*\d+)/.exec(value)?.[1] ?? '').split('.').map(Number);
const a = parts(left);
const b = parts(right);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if (a[index] === undefined) return -1;
if (b[index] === undefined) return 1;
if (a[index] !== b[index]) return a[index] - b[index];
}
return 0;
}

View File

@ -59,6 +59,8 @@ export const maesterDefinition: ToolProfileDefinition = {
firstRead: { firstRead: {
statusRowCounts: true, statusRowCounts: true,
nextStepLinkHeaders: [/^helpurl$/i], nextStepLinkHeaders: [/^helpurl$/i],
// Maester descriptions carry their remediation as a native `#### Remediation action:` subsection.
nextStepSection: { headers: [/^description$/i], heading: /(?:^|\s)#{2,6}[ \t]*Remediation action[ \t]*:?/i },
backgroundHeaders: [/^description$/i, /^test details$/i] backgroundHeaders: [/^description$/i, /^test details$/i]
}, },
titleHeaders: [/^title$/i], titleHeaders: [/^title$/i],

View File

@ -1,6 +1,7 @@
import type { ToolProfileDefinition } from '../../domain/tool-profile-definition'; import type { ToolProfileDefinition } from '../../domain/tool-profile-definition';
import { commonSeverityMappings, commonStatusMappings } from '../common-normalization'; import { commonSeverityMappings, commonStatusMappings } from '../common-normalization';
import { genericFieldRules } from '../generic/generic-field-rules'; import { genericFieldRules } from '../generic/generic-field-rules';
import { PURPLE_KNIGHT_RUN_NAMES } from './run-names';
export const purpleKnightDefinition: ToolProfileDefinition = { export const purpleKnightDefinition: ToolProfileDefinition = {
schemaVersion: 1, schemaVersion: 1,
@ -31,6 +32,7 @@ export const purpleKnightDefinition: ToolProfileDefinition = {
diagnosticPolicy: 'fail-closed-by-impact' diagnosticPolicy: 'fail-closed-by-impact'
}, },
name: 'PurpleKnight', name: 'PurpleKnight',
runNames: Object.values(PURPLE_KNIGHT_RUN_NAMES),
// 1.1.0: Das hier deklarierte Status-Mapping wird von der Importpipeline // 1.1.0: Das hier deklarierte Status-Mapping wird von der Importpipeline
// erstmals tatsaechlich angewendet (siehe src/importers/status-normalization.ts). // erstmals tatsaechlich angewendet (siehe src/importers/status-normalization.ts).
// Zuvor blieben "IOEs found", "Not Relevant", "Not selected" und "Canceled" // Zuvor blieben "IOEs found", "Not Relevant", "Not selected" und "Canceled"
@ -85,6 +87,9 @@ export const purpleKnightDefinition: ToolProfileDefinition = {
resultHeaders: [/^result\s*message$/i], resultHeaders: [/^result\s*message$/i],
affectedHeaders: [/^number\s+of\s+results?$/i], affectedHeaders: [/^number\s+of\s+results?$/i],
impactHeaders: [/^likelihood\s+of\s+compromise$/i], impactHeaders: [/^likelihood\s+of\s+compromise$/i],
// Exports append the indicator's reference titles to the likelihood text after a `References` heading.
// The heading follows a sentence end, or is glued to the last word when the export lost the punctuation.
impactReferenceMarker: /(?<=[.!?)])\s*>?\s*References\s*(?=\S)|(?<=[a-z])References(?=[A-Z])/,
nextStepHeaders: [/^remediation(?:\s+steps?)?$/i], nextStepHeaders: [/^remediation(?:\s+steps?)?$/i],
backgroundHeaders: [/^description$/i], backgroundHeaders: [/^description$/i],
referenceHeaders: [/^references$/i] referenceHeaders: [/^references$/i]

View File

@ -0,0 +1,10 @@
/**
* Scope-specific scan-run names the import assigns to Purple Knight runs.
* The profile declares the same names so a record's `Source: Scan` provenance
* resolves back to the Purple Knight profile instead of the generic fallback.
*/
export const PURPLE_KNIGHT_RUN_NAMES = {
ad: 'Purple Knight for Active Directory',
entra: 'Purple Knight for Entra ID',
combined: 'Purple Knight for Active Directory and Entra ID'
} as const;

View File

@ -0,0 +1,53 @@
import type { ReferenceCatalog, ReferenceCatalogEntry } from '../domain/reference-recognition';
const license = '© The MITRE Corporation. The MITRE Corporation (MITRE) hereby grants you a non-exclusive, royalty-free license to use ';
export const ATTACK_NOTICE = license + 'ATT&CK for research, development, and commercial purposes. Any copy you make for such purposes is authorized provided that you reproduce MITRE’s copyright designation and this license in any such copy. https://attack.mitre.org/resources/legal-and-branding/terms-of-use/';
export const D3FEND_NOTICE = license + "D3FEND for research, development, and commercial purposes. Any copy you make for such purposes is authorized provided that you reproduce MITRE's copyright designation and this license in any such copy. https://d3fend.mitre.org/tou/";
type ObjectValue = Record<string, unknown>;
const object = (value: unknown): ObjectValue => value && typeof value === 'object' && !Array.isArray(value) ? value as ObjectValue : {};
const list = (value: unknown): unknown[] => value === undefined ? [] : Array.isArray(value) ? value : [value];
const text = (value: unknown): string => typeof value === 'string' ? value : typeof object(value)['@value'] === 'string' ? object(value)['@value'] as string : '';
export function attackReferenceCatalog(raw: unknown, sourceUrl: string, sourceSha256: string): ReferenceCatalog {
const bundle = object(raw);
if (bundle.type !== 'bundle' || !Array.isArray(bundle.objects)) throw new Error('Expected Enterprise ATT&CK STIX.');
const objects = bundle.objects.map(object);
const collection = objects.find(item => item.type === 'x-mitre-collection' && item.name === 'Enterprise ATT&CK');
if (!collection || !/^\d+\.\d+$/.test(text(collection.x_mitre_version))) throw new Error('Missing Enterprise ATT&CK version.');
const tactics = new Map(objects.filter(item => item.type === 'x-mitre-tactic').map(item => [text(item.x_mitre_shortname), text(item.name)]));
const entries: ReferenceCatalogEntry[] = objects.flatMap(item => {
if (item.type !== 'attack-pattern' && item.type !== 'x-mitre-tactic') return [];
const ref = list(item.external_references).map(object).find(entry => entry.source_name === 'mitre-attack');
const id = text(ref?.external_id);
const title = text(item.name);
if (!/^(?:T\d{4}(?:\.\d{3})?|TA\d{4})$/.test(id) || !title) return [];
const kind = item.type === 'x-mitre-tactic' ? 'tactic' : 'technique';
return [{ id, title, kind, state: item.revoked === true ? 'revoked' : item.x_mitre_deprecated === true ? 'deprecated' : 'active',
url: `https://attack.mitre.org/${kind === 'tactic' ? 'tactics' : 'techniques'}/${id.replace('.', '/')}/`,
tactics: kind === 'tactic' ? [title] : [...new Set(list(item.kill_chain_phases).map(object)
.filter(phase => phase.kill_chain_name === 'mitre-attack').map(phase => tactics.get(text(phase.phase_name))).filter((value): value is string => Boolean(value)))] }];
});
if (!entries.some(entry => entry.kind === 'technique')) throw new Error('No ATT&CK techniques.');
return { schemaVersion: 1, framework: 'mitre-attack', version: text(collection.x_mitre_version), sourceUrl, sourceSha256,
license: ATTACK_NOTICE, entries: entries.sort((a, b) => a.id.localeCompare(b.id)) };
}
export function d3fendReferenceCatalog(raw: unknown, sourceUrl: string, sourceSha256: string): ReferenceCatalog {
const graph = object(raw)['@graph'];
if (!Array.isArray(graph)) throw new Error('Expected D3FEND JSON-LD.');
const nodes = graph.map(object);
const ontology = nodes.find(node => node['@id'] === 'http://d3fend.mitre.org/ontologies/d3fend.owl');
const version = text(ontology?.['owl:versionInfo']);
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('Missing D3FEND version.');
const entries: ReferenceCatalogEntry[] = nodes.flatMap(node => {
const id = text(node['d3f:d3fend-id']);
const title = text(node['rdfs:label']);
// D3A/D3E/ontology artifacts are outside the defensive-technique catalog.
if (!/^D3-[A-Z][A-Z0-9]*$/.test(id) || !title) return [];
return [{ id, title, kind: 'technique', state: node['owl:deprecated'] === true || object(node['owl:deprecated'])['@value'] === true ? 'deprecated' : 'active',
url: `https://d3fend.mitre.org/technique/${id}/`, tactics: [] }];
});
if (!entries.length || new Set(entries.map(entry => entry.id)).size !== entries.length) throw new Error('Invalid D3FEND technique identities.');
return { schemaVersion: 1, framework: 'mitre-d3fend', version, sourceUrl, sourceSha256, license: D3FEND_NOTICE,
entries: entries.sort((a, b) => a.id.localeCompare(b.id)) };
}

View File

@ -0,0 +1,30 @@
import type { FindingRecord } from '../domain/finding';
import { createReferenceRecognizer, type ReferenceCatalog, type ReferenceCatalogSnapshot } from '../domain/reference-recognition';
import d3fend from '../assets/data/d3fend-reference-v1.6.0.json';
interface GraphCatalog {
version: string;
tacticOrder: string[];
techniques: Array<{ id: string; name: string; tactics: string[]; url: string }>;
}
const recognizers = new WeakMap<GraphCatalog, ReturnType<typeof createReferenceRecognizer>>();
/** Stored occurrences exclusively use their frozen derivation. Transient previews use the same engine. */
export function findingReferences(record: FindingRecord, graph: GraphCatalog) {
if (record.referenceRecognition) return record.referenceRecognition.references;
if (record.findingOccurrenceId) return [];
let recognize = recognizers.get(graph);
if (!recognize) {
const catalog: ReferenceCatalogSnapshot = {
schemaVersion: 1, framework: 'mitre-attack', version: graph.version,
sourceUrl: '', sourceSha256: '', sha256: 'unpersisted-preview', license: 'MITRE ATT&CK — transient matrix preview',
entries: [...graph.techniques.map(item => ({ id: item.id, title: item.name, kind: 'technique' as const,
state: 'active' as const, url: item.url, tactics: item.tactics })),
...graph.tacticOrder.map(title => ({ id: `tactic:${title}`, title, kind: 'tactic' as const,
state: 'active' as const, url: '', tactics: [title] }))]
};
recognize = createReferenceRecognizer([catalog, { ...d3fend as ReferenceCatalog, sha256: 'unpersisted-preview' }]);
recognizers.set(graph, recognize);
}
return recognize(record.fields.map(field => ({ key: `column-${field.index}`, label: field.label, value: field.value })));
}

View File

@ -156,9 +156,10 @@ mark, .search-highlight {
/* Traegt seit der Zusammenfuehrung mit "Check title" die Pruefbeschreibung der /* Traegt seit der Zusammenfuehrung mit "Check title" die Pruefbeschreibung der
Regel, also einen Satz. Versalien waeren dafuer unlesbar. */ Regel, also einen Satz. Versalien waeren dafuer unlesbar. */
.reader-summary h3 { margin-bottom: var(--legacy-space-5); color: var(--heading); font-size: var(--type-h3-size); text-transform: none; } .reader-summary h3 { margin-bottom: var(--legacy-space-5); color: var(--heading); font-size: var(--type-h3-size); text-transform: none; }
.reader-summary h4 { margin: 0 0 7px; color: var(--heading); font-size: var(--ui-reading-size); text-transform: uppercase; } .reader-summary h4 { margin: 0 0 var(--space-2); color: var(--heading); font-size: var(--ui-reading-size); text-transform: uppercase; }
.reader-summary > div { padding: 0 0 15px; margin: 0 0 15px; border-bottom: 1px solid var(--line); } /* Native section elements share the same reading rhythm as ordinary fields. */
.reader-summary > div:last-child { margin-bottom: 0; } .reader-summary > :is(div, section) { padding: 0 0 var(--space-4); margin: 0 0 var(--space-4); border-bottom: var(--border-hair) solid var(--line); }
.reader-summary > :is(div, section):last-child { padding-bottom: 0; margin-bottom: 0; border-bottom: 0; }
.reader-summary > .policy-value-comparison { display: grid; gap: var(--legacy-space-2); padding: 0; border: 0; } .reader-summary > .policy-value-comparison { display: grid; gap: var(--legacy-space-2); padding: 0; border: 0; }
.policy-value-block { min-width: 0; padding: var(--legacy-space-3) var(--legacy-space-4); border-left: 4px solid var(--line-strong); background: var(--panel-soft); } .policy-value-block { min-width: 0; padding: var(--legacy-space-3) var(--legacy-space-4); border-left: 4px solid var(--line-strong); background: var(--panel-soft); }
.policy-value-comparison[data-outcome="fail"] .policy-value-block[data-value="configured"] { border-left-color: var(--critical); background: color-mix(in srgb, var(--critical) 8%, transparent); } .policy-value-comparison[data-outcome="fail"] .policy-value-block[data-value="configured"] { border-left-color: var(--critical); background: color-mix(in srgb, var(--critical) 8%, transparent); }
@ -188,6 +189,7 @@ mark, .search-highlight {
.policy-source-overview .policy-source-overview__note { color: var(--muted); font-size: var(--type-table-body-size); overflow-wrap: break-word; } .policy-source-overview .policy-source-overview__note { color: var(--muted); font-size: var(--type-table-body-size); overflow-wrap: break-word; }
.policy-source-groups { min-width: 0; border-top: var(--border-hair) solid var(--line-strong); } .policy-source-groups { min-width: 0; border-top: var(--border-hair) solid var(--line-strong); }
.policy-source-group { display: grid; gap: var(--space-2); min-width: 0; padding: var(--space-4) var(--space-3); border-bottom: var(--border-hair) solid var(--line-strong); background: transparent; } .policy-source-group { display: grid; gap: var(--space-2); min-width: 0; padding: var(--space-4) var(--space-3); border-bottom: var(--border-hair) solid var(--line-strong); background: transparent; }
.policy-source-group:last-child { padding-bottom: 0; border-bottom: 0; }
.policy-source-group__header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } .policy-source-group__header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.policy-source-group h5 { min-width: 0; margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.4; overflow-wrap: anywhere; } .policy-source-group h5 { min-width: 0; margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.4; overflow-wrap: anywhere; }
.policy-source-group__badge { width: fit-content; padding: 3px var(--space-2); font-size: var(--type-badge-size); font-weight: var(--fw-semibold); } .policy-source-group__badge { width: fit-content; padding: 3px var(--space-2); font-size: var(--type-badge-size); font-weight: var(--fw-semibold); }

View File

@ -44,6 +44,7 @@
.datahub-tenant-processing-dot { position: absolute; z-index: 3; top: var(--datahub-readiness-dot-inset); left: var(--datahub-readiness-dot-inset); width: var(--datahub-readiness-dot-size); height: var(--datahub-readiness-dot-size); border: var(--border-hair) solid var(--surface-app); border-radius: 50%; background: var(--state-online); pointer-events: none; } .datahub-tenant-processing-dot { position: absolute; z-index: 3; top: var(--datahub-readiness-dot-inset); left: var(--datahub-readiness-dot-inset); width: var(--datahub-readiness-dot-size); height: var(--datahub-readiness-dot-size); border: var(--border-hair) solid var(--surface-app); border-radius: 50%; background: var(--state-online); pointer-events: none; }
.datahub-tenant-processing-dot[data-status="update"], .datahub-tenant-processing-dot[data-status="update"],
.datahub-tenant-processing-dot[data-status="review"] { background: var(--warning); } .datahub-tenant-processing-dot[data-status="review"] { background: var(--warning); }
.datahub-tenant-processing-dot[data-status="checking"] { background: var(--text-secondary); }
.datahub-tenant-processing-dot[data-status="processing"], .datahub-tenant-processing-dot[data-status="processing"],
.datahub-tenant-processing-dot[data-status="error"] { background: var(--state-offline); } .datahub-tenant-processing-dot[data-status="error"] { background: var(--state-offline); }
.datahub-tenant-select { position: absolute; inset: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto auto; align-items: start; width: 100%; min-width: 0; padding: var(--space-5) var(--space-3) var(--space-3); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; } .datahub-tenant-select { position: absolute; inset: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto auto; align-items: start; width: 100%; min-width: 0; padding: var(--space-5) var(--space-3) var(--space-3); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
@ -97,7 +98,7 @@
der Karte - Version beziehungsweise Anzahl - steht als Plakette in der der Karte - Version beziehungsweise Anzahl - steht als Plakette in der
Kopfzeile und wird nicht darunter wiederholt. */ Kopfzeile und wird nicht darunter wiederholt. */
.datahub-rack-body { display: flex; align-items: center; justify-content: flex-end; gap: var(--legacy-space-2); min-height: 62px; padding: var(--legacy-space-2) var(--legacy-space-3); } .datahub-rack-body { display: flex; align-items: center; justify-content: flex-end; gap: var(--legacy-space-2); min-height: 62px; padding: var(--legacy-space-2) var(--legacy-space-3); }
.datahub-rack-actions { display: flex; align-items: center; gap: var(--legacy-space-2); } .datahub-rack-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--legacy-space-2); }
/* Gefuellte Aktion in Standardform und Standardgelb - keine eigene Hoehe, /* Gefuellte Aktion in Standardform und Standardgelb - keine eigene Hoehe,
kein eigener Ton mehr. */ kein eigener Ton mehr. */
.datahub-maintenance-action { border: var(--bw-hairline) solid var(--button-border); color: var(--button-marked-text); background: var(--button-marked-bg); font-weight: var(--ui-strong-weight); cursor: pointer; } .datahub-maintenance-action { border: var(--bw-hairline) solid var(--button-border); color: var(--button-marked-text); background: var(--button-marked-bg); font-weight: var(--ui-strong-weight); cursor: pointer; }
@ -112,7 +113,6 @@
.datahub-reference-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: var(--legacy-space-4); } .datahub-reference-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: var(--legacy-space-4); }
.datahub-reference-row > .mitre-library { grid-area: 1 / 1 / 2 / -1; } .datahub-reference-row > .mitre-library { grid-area: 1 / 1 / 2 / -1; }
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1 / 3 / -1; margin-top: 0; } .datahub-reference-row > .baseline-catalog { grid-area: 2 / 1 / 3 / -1; margin-top: 0; }
.datahub-reference-row.is-mitre-hidden > .baseline-catalog { grid-area: 1 / 1 / 2 / -1; }
.datahub-reference-row > .datahub-rack-module { margin-bottom: 0; } .datahub-reference-row > .datahub-rack-module { margin-bottom: 0; }
/* Der importierte Baseline-Katalog verwendet dieselbe aufklappbare Kopf- und /* Der importierte Baseline-Katalog verwendet dieselbe aufklappbare Kopf- und
Tabellenform wie die Scan-History im Mandantendetail. */ Tabellenform wie die Scan-History im Mandantendetail. */
@ -130,9 +130,48 @@
.datahub-reference-row { grid-template-columns: minmax(0, 1fr); } .datahub-reference-row { grid-template-columns: minmax(0, 1fr); }
.datahub-reference-row > .mitre-library { grid-area: 1 / 1; } .datahub-reference-row > .mitre-library { grid-area: 1 / 1; }
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1; } .datahub-reference-row > .baseline-catalog { grid-area: 2 / 1; }
.datahub-reference-row.is-mitre-hidden > .baseline-catalog { grid-area: 1 / 1; }
.tenant-timeslot-toolbar, .tenant-timeslot-create { grid-template-columns: 1fr; } .tenant-timeslot-toolbar, .tenant-timeslot-create { grid-template-columns: 1fr; }
.datahub-rack-header { grid-template-columns: 20px 10px minmax(0, 1fr) auto; } .datahub-rack-header { grid-template-columns: 20px 10px minmax(0, 1fr) auto; }
.datahub-rack-actions { justify-content: stretch; } .datahub-rack-actions { justify-content: stretch; }
.datahub-rack-actions > * { flex: 1; } .datahub-rack-actions > * { flex: 1; }
} }
/* Shared reference disclosure in client navigation and Finding Reader. */
.reference-recognition {
margin-block: var(--space-4);
font-size: var(--type-body-size);
}
.reference-recognition h4 { font-size: var(--type-h4-size); font-weight: 600; }
.reference-recognition-state strong { display: flex; align-items: center; gap: var(--space-2); font-weight: var(--fw-semibold); }
.reference-recognition-state > span { display: block; }
.reference-recognition-versions { margin: var(--space-3) 0 0; font-size: var(--type-table-body-size); }
.reference-recognition-disclosure > summary { cursor: pointer; list-style: none; border-radius: var(--btn-radius); }
.reference-recognition-disclosure > summary::-webkit-details-marker { display: none; }
.reference-recognition-disclosure > summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--space-1); }
.reference-recognition-disclosure > summary h4 { display: flex; align-items: center; gap: var(--space-2); min-height: var(--btn-h); margin: 0; }
.reference-recognition-chevron { margin-left: auto; color: var(--text-secondary); }
.reference-recognition-dot { display: inline-block; flex: 0 0 var(--space-2); width: var(--space-2); height: var(--space-2); border-radius: 50%; background: var(--state-open); }
.reference-recognition-dot[data-state="ready"] { background: var(--state-done); }
.reference-recognition-dot[data-state="attention"] { background: var(--state-blocked); }
.reference-recognition table { width: 100%; table-layout: fixed; font-size: var(--type-table-body-size); }
.reference-recognition th { width: 30%; font-weight: 400; text-transform: none; letter-spacing: normal; }
.reference-recognition td { overflow-wrap: anywhere; }
.reference-recognition summary { font-size: var(--type-table-body-size); }
.tenant-source-navigation {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-color: var(--border-strong) var(--surface-panel);
scrollbar-width: thin;
}
.tenant-source-navigation > .reference-recognition {
margin: 0;
padding: var(--space-4) var(--space-5);
border-bottom: var(--bw-hairline) solid var(--border-default);
}
/* User decision 2026-09-26: this action is red when processing is needed. */
.btn.reference-recognition-action[data-attention="true"],
.btn.reference-recognition-action[data-attention="true"]:hover {
color: var(--sev-on);
background: var(--state-offline);
border-color: var(--state-offline);
}

View File

@ -1327,7 +1327,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
padding: 0 var(--space-5) var(--space-3); padding: var(--space-3) var(--space-5);
border-bottom: var(--bw-hairline) solid var(--border-default); border-bottom: var(--bw-hairline) solid var(--border-default);
} }
.tenant-timeslots-heading { .tenant-timeslots-heading {

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => { describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => { it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.6.46'); expect(ASSESSOR_PRODUCT_VERSION).toBe('2.8.7');
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';"); expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
expect(app).toContain('aria-label="About ASSESSOR"'); expect(app).toContain('aria-label="About ASSESSOR"');
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';"); expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { createReferenceRecognizer } from '../../src/domain/reference-recognition';
import { bundledReferenceCatalogs, referenceSnapshot } from '../../server/reference-data/reference-catalogs';
const recognize = createReferenceRecognizer(bundledReferenceCatalogs);
const field = (value: string, label = 'Description') => ({ key: 'source', label, value, path: '/original/source' });
describe('framework-wide reference recognition', () => {
it('distinguishes declared IDs, mentioned IDs, unknown IDs and exact declared titles', () => {
expect(recognize([field('T1059.001; TA0002', 'MITRE ATT&CK')]).map(ref => [ref.id, ref.evidence, ref.status]))
.toEqual([['T1059.001', 'declared', 'active'], ['TA0002', 'declared', 'active']]);
expect(recognize([field('Consider T1059.001 and D3-CH, plus T9999.')]).map(ref => [ref.id, ref.evidence, ref.status]))
.toEqual([['T1059.001', 'mentioned', 'active'], ['T9999', 'mentioned', 'not-in-catalog'], ['D3-CH', 'mentioned', 'active']]);
expect(recognize([field('PowerShell; Execution', 'MITRE ATT&CK')]).map(ref => [ref.id, ref.matchedBy]))
// PowerShell names both a historical technique and a current sub-technique: do not guess.
.toEqual([['TA0002', 'exact-title']]);
expect(recognize([field('PowerShell; Execution; Credential Hardening')])).toEqual([]);
expect(recognize([field('Credential Hardening', 'D3FEND')])[0]).toMatchObject({ id: 'D3-CH', evidence: 'declared', matchedBy: 'exact-title' });
});
it('retains every repeated original occurrence and UTF-16 offsets without rewriting source values', () => {
const original = field('😀 See t1059.001 then T1059.001.');
const before = structuredClone(original);
const refs = recognize([original]);
expect(refs).toHaveLength(2);
for (const ref of refs) expect(original.value.slice(ref.source.start, ref.source.end)).toBe(ref.source.text);
expect(refs[0].source).toMatchObject({ start: 7, end: 16, offsetUnit: 'utf16', text: 't1059.001', fieldPath: '/original/source' });
expect(original).toEqual(before);
});
it('uses rawValue when retained and traces nested JSON leaves otherwise', () => {
expect(recognize([{ ...field('T1003'), rawValue: 'T1059.001' }])[0]).toMatchObject({ id: 'T1059.001', source: { representation: 'rawValue' } });
const refs = recognize([{ ...field(''), value: { 'a/b': ['T1003', 'D3-CH'] } }]);
expect(refs.map(ref => ref.source.valuePath)).toEqual(['/a~1b/0', '/a~1b/1']);
});
it.each(['T10590', 'T1059.0001', 'T1059.01', 'hostT1059', 'T1059host', 'x_T1059', 'D3-CH-MORE', 'N/A', '-', 'Txxxx'])('does not guess from %s', value => {
expect(recognize([field(value, 'MITRE ATT&CK')])).toEqual([]);
});
it('preserves deprecated/revoked catalog status and refuses ambiguous titles', () => {
const entries = bundledReferenceCatalogs[0].entries;
const historical = entries.find(entry => entry.state !== 'active')!;
expect(recognize([field(historical.id)])[0].status).toBe(historical.state);
const { sha256: _, ...catalog } = bundledReferenceCatalogs[0];
const ambiguous = referenceSnapshot({ ...catalog, entries: [{ ...entries[0], id: 'T9998', title: 'Ambiguous' }, { ...entries[0], id: 'T9999', title: 'Ambiguous' }] });
expect(createReferenceRecognizer([ambiguous])([field('Ambiguous', 'MITRE ATT&CK')])).toEqual([]);
});
});

View File

@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import type { ImportedDataset } from '../../src/domain/dataset';
import { POLICY_EVALUATION_ENGINE, type PolicyEvaluationResult, type StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { DatasetViewer } from '../../src/features/dataset-viewer/DatasetViewer';
import { appendPolicyObservationsToDataset } from '../../src/features/project-import/policy-observations-to-dataset';
import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records';
import { makeSheet } from '../../src/importers/sheet-factory';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/use-filtered-records', () => ({ useFilteredRecords: vi.fn() }));
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView');
});
/** Mixed project sheet as the canonical projection returns it: the CIS record knows its run's section titles. */
function scannerDataset(): ImportedDataset {
const sheet = makeSheet('Synthetic · All Records', [
['Source Rule ID', 'Title', 'Status', 'Source: Scan'],
['2.2.2', '2.2.2 Ensure a synthetic user right is restricted', 'Fail', 'CIS-CAT Pro · 2026-01-01T00:00:00.000Z']
], '', 'Synthetic', { isProjectSheet: true });
const record = sheet.analysis.records[0];
record.scanRunId = 'run-synthetic';
record.benchmarkSectionTitles = new Map([['2', 'Synthetic Local Policies'], ['2.2', 'Synthetic User Rights Assignment']]);
return { sheets: [sheet], rawText: '', profileId: 'generic', scanTool: 'Multiple Tools' } as unknown as ImportedDataset;
}
/** A timeslot that also holds GPO results: Work rebuilds the project sheet to append them. */
function withPolicyObservations(dataset: ImportedDataset): ImportedDataset {
const observation = {
sourceKind: 'policy-observation', sourceResultId: 'synthetic-observation', projectId: 'synthetic', assessmentId: 'slot',
policyBackupInstanceId: 'backup-1', title: 'Synthetic setting', importedAt: '2026-01-01T00:00:00Z',
policyScope: 'computer', policyDeliveryMechanism: 'registry-policy', policyIdentityStatus: 'resolved',
policyIdentityKey: 'synthetic-setting', policyValues: [{ kind: 'registry-policy', operation: 'set-value',
key: 'Software\\Policies\\Synthetic', valueName: 'Mode', typeCode: 4, data: { kind: 'uint32', value: 0 } }]
} as unknown as UnifiedResultProjection;
const comparison = {
id: 'synthetic-comparison', observationIds: [observation.sourceResultId], baselineId: 'baseline', baselineLabel: 'Synthetic baseline',
identity: { id: 'setting-synthetic', scope: 'computer', target: { kind: 'registry-value', key: 'Synthetic', valueName: 'Mode' } },
baselineState: 'explicit-recommendation', customerState: 'configured', classification: 'confirmed_configured_deviation', diagnosticCodes: [],
comparator: { version: 1, comparator: { kind: 'equality' }, relation: 'different',
baseline: { kind: 'integer', value: '1' }, actual: { kind: 'integer', value: '0' }, diagnosticCodes: [] }
} as unknown as PolicyEvaluationResult;
const evaluation = { run: { id: 'evaluation', projectId: 'synthetic', aggregationRunId: 'aggregation',
completedAt: '2026-01-01T00:00:00Z', evaluator: POLICY_EVALUATION_ENGINE }, results: [comparison] } as StoredPolicyEvaluation;
return appendPolicyObservationsToDataset(dataset, 'Synthetic', [observation], [evaluation])!;
}
// User correction 2026-09-26: Work headings name the CIS benchmark section instead of the rule number;
// the exact number stays visible as Source Rule ID in the report. Regression 2026-09-26: with GPO results
// in the timeslot, Work rebuilt the project sheet and dropped the titles, so the headings kept the number.
it.each([
['without GPO results', (dataset: ImportedDataset) => dataset],
['with GPO results appended to the project sheet', withPolicyObservations]
])('opens a CIS finding in mixed Work %s with the section title in the list and report headings', async (_case, project) => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: vi.fn(), configurable: true });
const dataset = project(scannerDataset());
const sheet = dataset.sheets[0];
const record = sheet.analysis.records.find(item => item.fields.some(field => field.label === 'Source Rule ID' && field.value === '2.2.2'))!;
expect(record.scanRunId).toBe('run-synthetic');
const state = {
dataset,
activeSheet: sheet,
view: 'records',
search: '',
titleColumn: 'Title',
selectedRecordId: record.id,
reviews: {},
facetFilters: {},
statusFilter: 'all',
actionOnly: false,
setView: vi.fn(),
setSelectedRecord: vi.fn(),
updateReview: vi.fn()
} as unknown as ReturnType<typeof useApp>;
vi.mocked(useApp).mockReturnValue(state);
vi.mocked(useFilteredRecords).mockReturnValue([record]);
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<DatasetViewer />));
const heading = 'Synthetic User Rights Assignment: Ensure a synthetic user right is restricted';
expect(container.querySelector('.finding-reader h2')?.textContent).toBe(heading);
expect(container.textContent!.split(heading).length).toBeGreaterThan(2);
expect(container.textContent).not.toContain('2.2.2 Ensure');
expect(container.querySelector('[data-source-rule-id] code')?.textContent).toBe('2.2.2');
expect([...container.querySelectorAll('.finding-reader [data-derived-category="cis"]')].map(badge => badge.getAttribute('title')))
.toEqual(['Synthetic Local Policies', 'Synthetic User Rights Assignment']);
expect(record.fields.find(field => field.label === 'Title')?.value).toBe('2.2.2 Ensure a synthetic user right is restricted');
} finally {
await act(async () => root.unmount());
container.remove();
}
});

View File

@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic timeslot</span> }));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); });
it.each(['populated', 'unavailable', 'non-numeric count'])('shows each overview fact once in the existing tiles and preserves Results: %s', async mode => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const snapshot = structuredClone(clientFactsSnapshot);
if (mode === 'unavailable') { snapshot.observations = []; snapshot.sections = []; }
if (mode === 'non-numeric count') {
const count = snapshot.observations.find(item => item.property === 'domain.controller_count')!;
count.originalValue = count.normalizedValue = 'Two reported controllers'; count.valueType = 'string';
}
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(snapshot);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.mocked(useApp).mockReturnValue(clientFactsState() as unknown as ReturnType<typeof useApp>);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={vi.fn()}/>));
const domain = host.querySelector('.client-domain-information')!;
const groups = [...host.querySelectorAll('.client-env-group')];
const directory = groups.find(group => group.querySelector('h3')?.textContent === 'Directory')!;
const inventory = groups.find(group => group.querySelector('h3')?.textContent === 'Inventory')!;
const labels = (element: Element) => [...element.querySelectorAll('dt')].map(item => item.textContent);
expect(host.querySelector('.client-posture')).not.toBeNull();
expect(host.querySelector('.client-posture-connectors')).not.toBeNull();
expect(host.querySelector('.client-priority')).not.toBeNull();
expect(host.querySelectorAll('.client-data-fact')).toHaveLength(6);
expect(groups).toHaveLength(5);
expect(labels(directory)).toEqual(['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']);
expect(labels(domain)).toEqual(mode === 'non-numeric count'
? ['Domain', 'Forest', 'Domain controllers', 'Domain functional level', 'Forest functional level', 'DC OS']
: ['Domain', 'Forest', 'Domain functional level', 'Forest functional level', 'DC OS']);
expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts', 'Assessed host']);
// Equal domain/forest and functional-level values are distinct facts, not duplicates.
if (mode !== 'unavailable') {
expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'contoso.example')).toHaveLength(2);
expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'Windows Server 2016')).toHaveLength(2);
} else expect([...domain.querySelectorAll('dd')].every(item => item.textContent === '—')).toBe(true);
for (const [title, group, expected] of [
['Directory', directory, ['Domain', 'Forest', 'Domains', 'Domain functional level', 'Schema version']],
['Inventory', inventory, ['User accounts', 'Computer objects', 'Domain controllers', 'Assessed host']]
] as const) {
const trigger = group.querySelector<HTMLButtonElement>('button')!;
expect(trigger.getAttribute('aria-label')).toBe(`Open ${title} results`);
await act(async () => trigger.click());
const popup = document.querySelector('.floating-data-window')!;
for (const label of expected) expect(labels(popup)).toContain(label);
if (mode !== 'unavailable') expect(popup.querySelector('table')).not.toBeNull();
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
expect(document.querySelector('.floating-data-window')).toBeNull();
}
} finally { await act(async () => root.unmount()); }
});

View File

@ -122,7 +122,7 @@ describe('client info environment detail', () => {
expect(component).not.toContain('className="client-environment-detail"'); expect(component).not.toContain('className="client-environment-detail"');
expect(component).not.toContain('environmentDetailRef'); expect(component).not.toContain('environmentDetailRef');
expect(component).toContain('<DashboardFactList items={items}/>'); expect(component).toContain('<DashboardFactList items={items}/>');
expect(component).toContain('<DashboardFactList items={tileItems.length ? tileItems : items.slice(0, 8)}/>'); expect(component).toContain('<DashboardFactList items={tileItems}/>');
expect(component).toContain('unit: \'/100 ASSESSOR\''); expect(component).toContain('unit: \'/100 ASSESSOR\'');
expect(component).toContain('PingCastle risk ${exactNative}/100'); expect(component).toContain('PingCastle risk ${exactNative}/100');
expect(component).toContain('useGpoInventory'); expect(component).toContain('useGpoInventory');

View File

@ -131,9 +131,9 @@ describe('Datahub centered reference rack layout', () => {
expect(datahubTableStyles).toContain('.tenant-report-status[data-status="review-required"] { color: var(--warning); }'); expect(datahubTableStyles).toContain('.tenant-report-status[data-status="review-required"] { color: var(--warning); }');
expect(datahubTableStyles).toContain('.tenant-processing-alert[data-status="error"]'); expect(datahubTableStyles).toContain('.tenant-processing-alert[data-status="error"]');
expect(datahubTableStyles).toContain('.tenant-processing-alert-inline {'); expect(datahubTableStyles).toContain('.tenant-processing-alert-inline {');
expect(datahub).toContain('const SHOW_MITRE_MATRIX_LIBRARY = false;'); expect(datahub).toContain('<MitreMatrixLibrary');
expect(datahub).toContain('{SHOW_MITRE_MATRIX_LIBRARY && <MitreMatrixLibrary'); expect(datahub).not.toContain('SHOW_MITRE_MATRIX_LIBRARY');
expect(datahubStyles).toContain('.datahub-reference-row.is-mitre-hidden > .baseline-catalog { grid-area: 1 / 1 / 2 / -1; }'); expect(datahubStyles).toContain('.datahub-reference-row > .mitre-library { grid-area: 1 / 1 / 2 / -1; }');
expect(baselines).toContain('<strong>Imported Security Baselines</strong>'); expect(baselines).toContain('<strong>Imported Security Baselines</strong>');
expect(baselines).toContain('className="tenant-scan-history baseline-catalog"'); expect(baselines).toContain('className="tenant-scan-history baseline-catalog"');
expect(baselines).not.toContain('className="tenant-scan-history baseline-catalog" open'); expect(baselines).not.toContain('className="tenant-scan-history baseline-catalog" open');

View File

@ -6,10 +6,10 @@ import type { ProjectAssessmentHistoryResponse } from '../../src/api/assessor-ap
import { DataHub } from '../../src/features/project-import/DataHub'; import { DataHub } from '../../src/features/project-import/DataHub';
import { DatahubMetricsCache, type TimeslotMetricsScope } from '../../src/features/project-import/datahub-metrics-cache'; import { DatahubMetricsCache, type TimeslotMetricsScope } from '../../src/features/project-import/datahub-metrics-cache';
const api = vi.hoisted(() => ({ health: vi.fn(), listProjects: vi.fn(), listProjectImportFiles: vi.fn(), const api = vi.hoisted(() => ({ health: vi.fn(), getMitreCatalog: vi.fn(), listProjects: vi.fn(), listProjectImportFiles: vi.fn(),
listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(), getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn() })); listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(), getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn() }));
vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class { vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class {
health = api.health; listProjects = api.listProjects; listProjectImportFiles = api.listProjectImportFiles; health = api.health; getMitreCatalog = api.getMitreCatalog; listProjects = api.listProjects; listProjectImportFiles = api.listProjectImportFiles;
listProjectAssessmentHistory = api.listProjectAssessmentHistory; listProjectGpoBackupStatus = api.listProjectGpoBackupStatus; listProjectAssessmentHistory = api.listProjectAssessmentHistory; listProjectGpoBackupStatus = api.listProjectGpoBackupStatus;
getTimeslotResultMetrics = api.getTimeslotResultMetrics; queryUnifiedResultFacets = api.queryUnifiedResultFacets; getTimeslotResultMetrics = api.getTimeslotResultMetrics; queryUnifiedResultFacets = api.queryUnifiedResultFacets;
} })); } }));
@ -28,6 +28,7 @@ it('preloads during overview loading, shares pending requests, reuses counts on
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
let revision = 700; let revision = 700;
api.health.mockImplementation(async () => ({ status: 'ok', repositoryRevision: revision })); api.health.mockImplementation(async () => ({ status: 'ok', repositoryRevision: revision }));
api.getMitreCatalog.mockResolvedValue({ catalog: { version: '19.2', tacticOrder: ['Execution'], techniques: [{ id: 'T1234', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } });
api.listProjects.mockResolvedValue(['alpha', 'beta'].map(id => ({ id, name: id, attributes: {}, createdAt: date, updatedAt: date }))); api.listProjects.mockResolvedValue(['alpha', 'beta'].map(id => ({ id, name: id, attributes: {}, createdAt: date, updatedAt: date })));
api.listProjectImportFiles.mockResolvedValue([]); api.listProjectImportFiles.mockResolvedValue([]);
api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] }); api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] });

View File

@ -0,0 +1,232 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { DataHub } from '../../src/features/project-import/DataHub';
import { withReferenceReadiness } from '../../src/features/project-import/datahub-processing-readiness';
const api = vi.hoisted(() => ({ health: vi.fn(), getMitreCatalog: vi.fn(), listProjects: vi.fn(),
listProjectImportFiles: vi.fn(), listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(),
getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn(), getReferenceRecognitionStatus: vi.fn(),
recognizeReferences: vi.fn(), refreshMitreCatalog: vi.fn(), listProjectAssessments: vi.fn(), loadAssessmentModel: vi.fn() }));
vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class {
health = api.health; getMitreCatalog = api.getMitreCatalog; listProjects = api.listProjects;
listProjectImportFiles = api.listProjectImportFiles; listProjectAssessmentHistory = api.listProjectAssessmentHistory;
listProjectGpoBackupStatus = api.listProjectGpoBackupStatus; getTimeslotResultMetrics = api.getTimeslotResultMetrics;
queryUnifiedResultFacets = api.queryUnifiedResultFacets; getReferenceRecognitionStatus = api.getReferenceRecognitionStatus;
recognizeReferences = api.recognizeReferences;
refreshMitreCatalog = api.refreshMitreCatalog;
listProjectAssessments = api.listProjectAssessments; loadAssessmentModel = api.loadAssessmentModel;
} }));
vi.mock('../../src/features/project-import/MicrosoftBaselineLibrary', () => ({ MicrosoftBaselineLibrary: () => null }));
vi.mock('../../src/features/file-import/FileImport', () => ({ FileImport: () => null }));
vi.mock('../../src/features/project-import/GpoFolderImport', () => ({ GpoFolderImport: () => null }));
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); });
const projectId = 'synthetic-reference-client';
const referenceStatus = (pendingScans: number, indexedScans = 2) => ({ totalScans: 2, indexedScans, pendingScans,
catalogs: [{ framework: 'mitre-attack', version: '19.2', sha256: 'synthetic-hash' }] });
function fixture() {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const date = '2026-01-01T00:00:00Z';
api.health.mockResolvedValue({ status: 'ok', repositoryRevision: 801 });
api.getMitreCatalog.mockResolvedValue({ catalog: { version: '19.2', tacticOrder: ['Execution'],
techniques: [{ id: 'T1059', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } });
api.listProjects.mockResolvedValue([{ id: projectId, name: 'Synthetic client', attributes: {}, createdAt: date, updatedAt: date }]);
api.listProjectImportFiles.mockResolvedValue([]);
api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] });
api.listProjectAssessmentHistory.mockResolvedValue({ version: 1, projectId,
assessments: ['first', 'second'].map(id => ({ id, projectId, name: `Synthetic ${id}`, createdAt: date, updatedAt: date, effectiveAt: date })),
scanRuns: ['first', 'second'].map(id => ({ id: `${id}-run`, assessmentId: id, sequence: 1, label: 'Synthetic scan', importedAt: date,
tool: { id: 'synthetic', name: 'Synthetic scanner' } })) });
api.getTimeslotResultMetrics.mockResolvedValue({ all: 2, deviation: 1, passed: 1, notApplicable: 0, notRun: 0, error: 0 });
api.queryUnifiedResultFacets.mockResolvedValue({ total: 2, facets: [] });
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2, 0));
api.recognizeReferences.mockImplementation(async () => {
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0));
return { processedScans: 2, unchangedScans: 0, references: 4 };
});
}
it.each([0, 2])('warns before client selection for pending references (indexed=%i), and clears the warning after Manage recognition', async indexedScans => {
fixture();
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2, indexedScans));
const changed = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined} onProjectDataChanged={changed}/>));
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('update');
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('update');
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('title')).toContain('2 scans require reference recognition');
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
const panel = host.querySelector('.tenant-detail-navigation .reference-recognition');
expect(panel?.nextElementSibling?.classList.contains('tenant-timeslots')).toBe(true);
expect(host.querySelector('.tenant-information-sheet .reference-recognition')).toBeNull();
expect(panel?.textContent).toContain('Catalog references');
const disclosure = panel!.querySelector('details')!;
expect(disclosure.open).toBe(true);
expect(disclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
await act(async () => disclosure.querySelector('summary')!.click());
expect(disclosure.open).toBe(false);
await act(async () => disclosure.querySelector('summary')!.click());
expect(disclosure.open).toBe(true);
expect(panel?.querySelector('[role="status"]')?.textContent).toContain('Please update');
expect(panel?.textContent).not.toContain('Recognize ATT&CK and D3FEND references in');
const button = panel!.querySelector<HTMLButtonElement>('button')!;
expect(button.textContent).toBe('Update references');
expect(button.disabled).toBe(false);
expect(button.dataset.attention).toBe('true');
expect(button.classList.contains('primary')).toBe(true);
await act(async () => button.click());
expect(api.recognizeReferences).toHaveBeenCalledExactlyOnceWith(projectId);
expect(changed).toHaveBeenCalledExactlyOnceWith(projectId);
expect(panel?.querySelector('[role="status"]')?.textContent).toContain('Update successful');
expect(panel?.querySelector('[role="status"]')?.textContent).toContain('No action needed.');
expect(button.dataset.attention).toBe('false');
expect(disclosure.open).toBe(true);
expect(disclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
expect(disclosure.querySelector('[role="status"] .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-edit-client')!.click());
expect(host.querySelector('.tenant-detail-navigation .reference-recognition')).toBe(panel);
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
expect(host.querySelector('.datahub-tenant-show')).toBeNull();
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-select')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
expect(host.querySelector('.reference-recognition [role="status"]')?.textContent).toBe('Up to dateNo action needed.');
expect(host.querySelector('.reference-recognition-versions')?.textContent).toContain('ATT&CK 19.2');
const currentDisclosure = host.querySelector<HTMLDetailsElement>('.reference-recognition-disclosure')!;
expect(currentDisclosure.open).toBe(false);
expect(currentDisclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
expect(host.querySelector('.reference-recognition-versions')?.closest('details')).toBeNull();
await act(async () => currentDisclosure.querySelector('summary')!.click());
expect(currentDisclosure.open).toBe(true);
expect(currentDisclosure.querySelector('[role="status"] .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
} finally { await act(async () => root.unmount()); }
});
it('keeps a recognition failure red on return and allows a successful retry', async () => {
fixture();
api.recognizeReferences.mockRejectedValueOnce(new Error('Synthetic recognition failure'));
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
expect(host.querySelector('.reference-recognition [role="alert"]')?.textContent).toContain('Synthetic recognition failure');
expect(host.querySelector<HTMLDetailsElement>('.reference-recognition-disclosure')!.open).toBe(true);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('error');
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('error');
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
} finally { await act(async () => root.unmount()); }
});
it('does not let a slow overview status overwrite successful recognition in the client panel', async () => {
fixture();
let resolveOld!: (status: ReturnType<typeof referenceStatus>) => void;
api.getReferenceRecognitionStatus.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve; }));
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('checking');
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-select')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
await act(async () => resolveOld(referenceStatus(2, 0)));
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
} finally { await act(async () => root.unmount()); }
});
it('keeps unavailable status visible and offers a working status retry in Manage', async () => {
fixture();
api.getReferenceRecognitionStatus.mockRejectedValueOnce(new Error('Synthetic unavailable')).mockRejectedValueOnce(new Error('Synthetic unavailable'));
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('review');
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
const retry = [...host.querySelectorAll<HTMLButtonElement>('.reference-recognition button')].find(button => button.textContent === 'Retry status')!;
expect(host.querySelector<HTMLDetailsElement>('.reference-recognition-disclosure')!.open).toBe(true);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
await act(async () => retry.click());
expect(host.querySelector<HTMLButtonElement>('.reference-recognition button')!.disabled).toBe(false);
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('update');
} finally { await act(async () => root.unmount()); }
});
it('refreshes reference warnings after a catalog update and a changed import', async () => {
fixture();
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0));
api.refreshMitreCatalog.mockImplementation(async () => {
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2));
return { updated: true, catalog: { version: '19.3', tacticOrder: ['Execution'],
techniques: [{ id: 'T1059', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } };
});
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
expect(host.querySelector('.datahub-tenant-show')).toBeNull();
await act(async () => host.querySelector<HTMLButtonElement>('.mitre-library .datahub-maintenance-action')!.click());
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('update');
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0));
await act(async () => globalThis.dispatchEvent(new Event('assessor:datahub-changed')));
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
} finally { await act(async () => root.unmount()); }
});
it('preserves scanner and GPO error priority alongside a reference update', () => {
const result = withReferenceReadiness({ state: 'error', label: 'Synthetic GPO error.' },
{ state: 'ready', status: { ...referenceStatus(2), catalogs: [] } });
expect(result.state).toBe('error');
expect(result.label).toContain('Synthetic GPO error.');
expect(result.label).toContain('2 scans require reference recognition');
});
it.each(['run', 'file', 'observation'])('names AD and Entra scans explicitly after Manage and timeslot expansion, using retained %s scope', async source => {
fixture();
const date = '2026-01-01T00:00:00Z';
const expected = ['Purple Knight for Active Directory', 'Purple Knight for Entra ID',
'Purple Knight for Active Directory and Entra ID', 'PurpleKnight'];
const tool = { id: 'purple-knight', name: 'PurpleKnight' };
const assessment = { id: 'synthetic-scope-assessment', projectId, name: 'Synthetic timeslot', createdAt: date, updatedAt: date, effectiveAt: date };
const runs = expected.map((name, index) => ({ id: `scope-${index}`, assessmentId: assessment.id, sequence: index + 1,
label: 'Untrusted title says Entra ID', importedAt: date, tool: { ...tool, name: source === 'run' ? name : tool.name } }));
const files = runs.map((run, index) => ({ id: `file-${run.id}`, scanRunId: run.id, fileName: 'untrusted-entra-ad.xlsx',
format: 'Excel', sizeBytes: 100, importedAt: date, detectedTool: { ...tool, name: source === 'file' ? expected[index] : tool.name },
detection: { level: 'high', score: 1, reasons: ['synthetic'] }, sourceArtifact: { kind: 'not-retained', reason: 'Synthetic' },
processedWith: { parserId: 'xlsx', parserVersion: '1.0.0', profileId: tool.id, profileVersion: '1.0.0', mappingVersion: '1.0.0' } }));
const properties = ['purple_knight.ad.security_posture_score', 'purple_knight.entra.security_posture_score'];
const observations = source === 'observation' ? [0, 1, 2].flatMap(index => (index === 2 ? properties : [properties[index]])
.map(property => ({ scanRunId: runs[index].id, importFileId: files[index].id, tool, property, originalValue: 70 }))) : [];
// Unrelated tool and file facts must never assign Entra scope to the unknown fourth run.
observations.push({ scanRunId: runs[3].id, importFileId: files[3].id, tool: { id: 'maester', name: 'Maester' }, property: properties[1], originalValue: 70 });
observations.push({ scanRunId: runs[3].id, importFileId: 'foreign-file', tool, property: properties[1], originalValue: 70 });
api.listProjectAssessmentHistory.mockResolvedValue({ version: 1, projectId, assessments: [assessment], scanRuns: runs });
api.listProjectImportFiles.mockResolvedValue(files);
api.listProjectAssessments.mockResolvedValue([assessment]);
api.loadAssessmentModel.mockResolvedValue({ scanRuns: runs, importFiles: files, environmentObservations: observations,
findingOccurrences: runs.map(run => ({ id: `occurrence-${run.id}`, scanRunId: run.id })), findingDefinitions: [], evidence: [], diagnostics: [], reviews: [] });
const originalRuns = structuredClone(runs);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
const timeslot = host.querySelector<HTMLDetailsElement>('.tenant-timeslot')!;
if (!timeslot.open) await act(async () => timeslot.querySelector('summary')!.click());
expect(timeslot.open).toBe(true);
expect([...timeslot.querySelectorAll('.tenant-report-copy b')].map(element => element.textContent)).toEqual(expected);
for (const name of expected) {
expect(timeslot.querySelector(`input[aria-label="Select ${name} scan report"]`)).not.toBeNull();
expect(timeslot.querySelector(`button[aria-label="Delete ${name} scan report"]`)).not.toBeNull();
}
expect(runs).toEqual(originalRuns);
} finally { await act(async () => root.unmount()); }
});

View File

@ -7,6 +7,7 @@ import { cisCatProfile } from '../../src/profiles/cis-cat/cis-cat-profile';
import { deriveDynamicFacets, recountFacetsAgainstActiveFilters, sourceScanNavigationSheet } from '../../src/features/search-filter/dynamic-facets'; import { deriveDynamicFacets, recountFacetsAgainstActiveFilters, sourceScanNavigationSheet } from '../../src/features/search-filter/dynamic-facets';
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records'; import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
import { securityDomainCatalog } from '../../src/features/dashboard/security-domains'; import { securityDomainCatalog } from '../../src/features/dashboard/security-domains';
import { cisBenchmarkSectionTitles } from '../../src/profiles/cis-cat/cis-benchmark-section-titles';
const sheet = makeSheet('Findings', [ const sheet = makeSheet('Findings', [
['Title', 'Severity', 'Status', 'Score', 'Description'], ['Title', 'Severity', 'Status', 'Score', 'Description'],
@ -338,6 +339,56 @@ describe('dynamic facets', () => {
expect(records.map(record => record.title)).toEqual(['17.2.4 Ensure account management auditing']); expect(records.map(record => record.title)).toEqual(['17.2.4 Ensure account management auditing']);
}); });
describe('CIS category section titles', () => {
const scoreSection = (scanRunId: string, rows: string[][]) => ({
version: 1 as const, sectionKey: 'cis-cat-section-scores', label: 'Benchmark section scores', ordinal: 1, state: 'observed' as const,
tables: [{ name: 'Section scores', headers: ['Description', 'Percent'], rows }], source: { fileName: 'synthetic.html' }, attributes: {}, scanRunId
});
const cisSheet = (runs: string[]) => {
const created = makeSheet('assessmentResultTable', [
['Rule ID', 'Benchmark Item', 'Result'],
['17.2.4', '17.2.4 Ensure synthetic auditing', 'Fail'],
['2.3.10.1', '2.3.10.1 Ensure synthetic network access', 'Fail'],
['18.10.42.1', '18.10.42.1 Ensure synthetic antivirus', 'Fail']
], '', 'cis-cat-synthetic.html', { toolId: 'cis-cat' });
created.analysis.records.forEach((record, index) => { record.scanRunId = runs[index % runs.length]; });
return created;
};
const titles = (sections: ReturnType<typeof scoreSection>[]) => cisBenchmarkSectionTitles(sections);
const labels = (sheet: ReturnType<typeof cisSheet>, dataset: unknown) => deriveDynamicFacets(sheet, dataset as never, cisCatProfile, {})
.find(item => item.id === 'derived:cis-benchmark-category')?.values.map(value => value.label);
const syntheticRows = [['17 Synthetic Audit Policy', '50'], ['17.2 Synthetic Account Management', '50'], ['2 Synthetic Local Policies', '10'],
['2.3 Synthetic Security Options', '10'], ['2.3.10 Synthetic Network access', '10'], ['18 Synthetic Templates', '20'],
['18.10 Synthetic Components', '20'], ['18.10.42 Synthetic Antivirus', '20']];
it('names each number from its own run report and orders the numbers numerically', () => {
const sheet = cisSheet(['run-a']);
expect(labels(sheet, { sheets: [sheet], benchmarkSectionTitles: titles([scoreSection('run-a', syntheticRows)]) })).toEqual([
'CIS 2 · Synthetic Local Policies', 'CIS 2.3 · Synthetic Security Options', 'CIS 2.3.10 · Synthetic Network access',
'CIS 17 · Synthetic Audit Policy', 'CIS 17.2 · Synthetic Account Management',
'CIS 18 · Synthetic Templates', 'CIS 18.10 · Synthetic Components', 'CIS 18.10.42 · Synthetic Antivirus']);
// The selectable value stays the number, so filtering is unchanged.
const records = filterAndSortRecords(sheet, { activeProfile: cisCatProfile, search: '', sortColumn: 'all', sortDirection: 'desc',
statusFilter: 'all', facetFilters: { 'derived:cis-benchmark-category': { values: ['cis 17.2'] } }, actionOnly: false, reviews: {} });
expect(records.map(record => record.title)).toEqual(['17.2.4 Ensure synthetic auditing']);
});
it('keeps the bare number when runs disagree or a run lists no title', () => {
const sheet = makeSheet('assessmentResultTable', [
['Rule ID', 'Benchmark Item', 'Result'],
['18.10.42.1', '18.10.42.1 Ensure synthetic antivirus', 'Fail'],
['18.10.42.2', '18.10.42.2 Ensure synthetic scanning', 'Fail']
], '', 'cis-cat-synthetic.html', { toolId: 'cis-cat' });
sheet.analysis.records[0].scanRunId = 'run-a';
sheet.analysis.records[1].scanRunId = 'run-b';
const other = syntheticRows.map(([title, percent]) => [title.startsWith('18 ') ? '18 Different synthetic title' : title, percent]);
expect(labels(sheet, { sheets: [sheet], benchmarkSectionTitles: titles([scoreSection('run-a', syntheticRows), scoreSection('run-b', other)]) }))
.toEqual(['CIS 18', 'CIS 18.10 · Synthetic Components', 'CIS 18.10.42 · Synthetic Antivirus']);
expect(labels(cisSheet(['run-c']), { sheets: [], benchmarkSectionTitles: titles([scoreSection('run-a', syntheticRows)]) }))
.toEqual(['CIS 2', 'CIS 2.3', 'CIS 2.3.10', 'CIS 17', 'CIS 17.2', 'CIS 18', 'CIS 18.10', 'CIS 18.10.42']);
});
});
it('places the Dummy Windows hardening checks in System Hardening under the Work domain facet', () => { it('places the Dummy Windows hardening checks in System Hardening under the Work domain facet', () => {
const checks = [ const checks = [
["18.1.1.1", "Ensure 'Prevent enabling lock screen camera' is set to 'Enabled'"], ["18.1.1.1", "Ensure 'Prevent enabling lock screen camera' is set to 'Enabled'"],

View File

@ -1,5 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { POLICY_EVALUATION_ENGINE, type PolicyEvaluationResult, type StoredPolicyEvaluation } from '../../src/domain/policy-evaluation'; import { POLICY_EVALUATION_ENGINE, type PolicyEvaluationResult, type StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
@ -55,6 +56,43 @@ function read(observation: UnifiedResultProjection, evaluations: StoredPolicyEva
const count = (text: string, part: string) => text.split(part).length - 1; const count = (text: string, part: string) => text.split(part).length - 1;
describe('GPO Finding Reader baseline comparison', () => { describe('GPO Finding Reader baseline comparison', () => {
it('keeps source, baseline and following field spacing when opening and closing comparisons', () => {
const { observation, evaluation } = fixture([1, 2]);
const { container } = read(observation, [evaluation]);
const tokens = readFileSync('src/styles/ui-tokens.css', 'utf8');
const spacing = tokens.match(/--space-4:\s*([^;]+);/)![1];
const style = document.createElement('style');
// jsdom does not resolve custom properties; retain the actual production rules.
style.textContent = readFileSync('src/styles/components.css', 'utf8')
.replaceAll('var(--space-4)', spacing).replaceAll('var(--border-hair)', '1px')
.replaceAll('var(--line)', 'gray').replaceAll('var(--line-strong)', 'gray');
document.head.append(style);
document.body.append(container);
try {
const sources = container.querySelector('.policy-source-evidence')!;
const baseline = container.querySelector('.policy-baseline-comparison')!;
const details = baseline.querySelector('details')!;
const checkSpacing = () => {
for (const section of [sources, baseline]) {
expect(getComputedStyle(section).paddingBottom).toBe(spacing);
expect(getComputedStyle(section).marginBottom).toBe(spacing);
}
expect(getComputedStyle(sources.querySelector('.policy-source-group:last-child')!).borderBottomWidth).toBe('0px');
const last = container.querySelector('.reader-summary > :last-child')!;
expect(getComputedStyle(last).borderBottomWidth).toBe('0px');
};
checkSpacing();
(details.querySelector('summary') as HTMLElement).click();
expect(details.open).toBe(true);
checkSpacing();
(details.querySelector('summary') as HTMLElement).click();
expect(details.open).toBe(false);
checkSpacing();
} finally {
container.remove();
style.remove();
}
});
it('states identical variant outcomes once and keeps every stored comparison in a closed disclosure', () => { it('states identical variant outcomes once and keeps every stored comparison in a closed disclosure', () => {
const { observation, evaluation } = fixture([1, 2, 3]); const { observation, evaluation } = fixture([1, 2, 3]);
const { record, container } = read(observation, [evaluation]); const { record, container } = read(observation, [evaluation]);

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildFindingPathItems, extractFindingMitreFocus, sharedEntityIds, type MitreEntity, type MitreGraphData } from '../../src/features/mitre-graph/mitre-graph'; import { buildFindingPathItems, defaultMitreGraphData, extractFindingMitreFocus, sharedEntityIds, validateMitreMatrixData, type MitreEntity, type MitreGraphData } from '../../src/features/mitre-graph/mitre-graph';
import { convertEnterpriseStix } from '../../src/features/mitre-graph/mitre-stix';
import type { FindingRecord } from '../../src/domain/finding'; import type { FindingRecord } from '../../src/domain/finding';
const record = (value: string): FindingRecord => ({ const record = (value: string): FindingRecord => ({
@ -12,6 +13,36 @@ const record = (value: string): FindingRecord => ({
}); });
describe('MITRE graph', () => { describe('MITRE graph', () => {
it('ships the current Enterprise catalog with the new Stealth tactic', () => {
expect(defaultMitreGraphData.version).toBe('19.2');
expect(defaultMitreGraphData.tacticOrder).toContain('Stealth');
expect(defaultMitreGraphData.techniques.find(technique => technique.id === 'T1055.011')?.tactics).toContain('Stealth');
});
it('converts an official-style STIX bundle, excluding revoked techniques and relations', () => {
const raw = { type: 'bundle', objects: [
{ type: 'x-mitre-collection', name: 'Enterprise ATT&CK', x_mitre_version: '19.2' },
{ id: 'matrix', type: 'x-mitre-matrix', name: 'Enterprise ATT&CK', tactic_refs: ['tactic'] },
{ id: 'tactic', type: 'x-mitre-tactic', name: 'Stealth', x_mitre_shortname: 'stealth' },
{ id: 'pattern', type: 'attack-pattern', name: 'Synthetic technique', external_references: [{ source_name: 'mitre-attack', external_id: 'T9999', url: 'https://attack.mitre.org/techniques/T9999' }], kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'stealth' }] },
{ id: 'old', type: 'attack-pattern', name: 'Old', revoked: true, external_references: [{ source_name: 'mitre-attack', external_id: 'T9998' }], kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'stealth' }] },
{ id: 'group', type: 'intrusion-set', name: 'Synthetic group', external_references: [{ source_name: 'mitre-attack', external_id: 'G9999' }] },
{ type: 'relationship', relationship_type: 'uses', source_ref: 'group', target_ref: 'pattern' },
{ type: 'relationship', relationship_type: 'uses', source_ref: 'group', target_ref: 'old' }
] };
const converted = convertEnterpriseStix(raw);
expect(converted).toEqual({
version: '19.2', tacticOrder: ['Stealth'],
techniques: [{ id: 'T9999', name: 'Synthetic technique', tactics: ['Stealth'], platforms: [], parent: null, url: 'https://attack.mitre.org/techniques/T9999', sourceCount: 1 }],
entities: [{ id: 'G9999', name: 'Synthetic group', type: 'group', techniques: ['T9999'] }]
});
expect(validateMitreMatrixData(raw)).toEqual(converted);
});
it('rejects unrelated STIX bundles and incomplete compact files', () => {
expect(() => convertEnterpriseStix({ type: 'bundle', objects: [] })).toThrow('Enterprise ATT&CK');
expect(() => validateMitreMatrixData({ version: '19.2', tacticOrder: ['Stealth'], techniques: [] })).toThrow('techniques');
});
it('erkennt Technik-IDs und Taktiknamen aus Findings', () => { it('erkennt Technik-IDs und Taktiknamen aus Findings', () => {
const focus = extractFindingMitreFocus( const focus = extractFindingMitreFocus(
[record('T1059.001; Persistence / Privilege Escalation')], [record('T1059.001; Persistence / Privilege Escalation')],
@ -54,7 +85,9 @@ describe('MITRE graph', () => {
const item = buildFindingPathItems([source], data)[0]; const item = buildFindingPathItems([source], data)[0];
expect(item.categories).toEqual(['Identity', 'Lateral Movement']); expect(item.categories).toEqual(['Identity', 'Lateral Movement']);
expect(item.defenses).toEqual(['Harden - Credential Hardening', 'Detect - Account Monitoring']); // Only exact catalog entries form relations; the old unchecked Account Monitoring text remains in the source field.
expect(item.defenses).toEqual(['D3-CH · Credential Hardening']);
expect(source.fields[2].value).toContain('Account Monitoring');
expect(item.targets).toEqual(['DC-01', 'Tier 0']); expect(item.targets).toEqual(['DC-01', 'Tier 0']);
expect(item.tactics).toEqual(['Execution', 'Persistence']); expect(item.tactics).toEqual(['Execution', 'Persistence']);
}); });

View File

@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { MitreMatrixLibrary } from '../../src/features/project-import/MitreMatrixLibrary';
afterEach(() => vi.unstubAllGlobals());
it('offers automatic MITRE update, original download, and manual JSON fallback', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const onRefresh = vi.fn();
const onDownload = vi.fn();
const onUpload = vi.fn();
const onReset = vi.fn();
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<MitreMatrixLibrary status={null} activeVersion="19.2" busy={null} feedback={null} error={null} onRefresh={onRefresh} onDownload={onDownload} onUpload={onUpload} onReset={onReset} />));
expect(container.textContent).toContain('v19.2 Active');
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'Update from MITRE')!.click());
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'Download from MITRE')!.click());
expect(onRefresh).toHaveBeenCalledOnce();
expect(onDownload).toHaveBeenCalledOnce();
expect(container.textContent).toContain('Import JSON file');
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
const uploadButton = container.querySelector<HTMLElement>('.datahub-mitre-upload-btn')!;
expect(uploadButton.getAttribute('tabindex')).toBe('0');
const fileClick = vi.spyOn(input, 'click');
await act(async () => uploadButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })));
expect(fileClick).toHaveBeenCalledOnce();
await act(async () => input.dispatchEvent(new Event('change', { bubbles: true })));
expect(onUpload).toHaveBeenCalledOnce();
expect(container.textContent).not.toContain('Use active catalog');
await act(async () => root.render(<MitreMatrixLibrary status={{ version: '19.1', tacticOrder: [], techniques: [], entities: [] }} activeVersion="19.1" busy={null} feedback={null} error={null} onRefresh={onRefresh} onDownload={onDownload} onUpload={onUpload} onReset={onReset} />));
expect(container.textContent).toContain('Custom v19.1');
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'Use active catalog')!.click());
expect(onReset).toHaveBeenCalledOnce();
} finally {
await act(async () => root.unmount());
container.remove();
}
});

View File

@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import type { ImportedDataset } from '../../src/domain/dataset';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { createReferenceRecognizer } from '../../src/domain/reference-recognition';
import { bundledReferenceCatalogs } from '../../server/reference-data/reference-catalogs';
import { appendPolicyObservationsToDataset } from '../../src/features/project-import/policy-observations-to-dataset';
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
import { makeSheet } from '../../src/importers/sheet-factory';
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); });
it.each([false, true])('retains recognized references when opening the scanner Reader (GPOs: %s)', async withGpos => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const sheet = makeSheet('All Records', [
['Title', 'Status', 'Category'],
['Synthetic scanner finding', 'Fail', 'Synthetic'],
['Synthetic indexed finding without references', 'Pass', 'Synthetic'],
['Synthetic unindexed finding', 'Fail', 'Synthetic']
], '', 'Synthetic', { isProjectSheet: true });
const references = createReferenceRecognizer(bundledReferenceCatalogs)([
{ key: 'attack', label: 'MITRE ATT&CK', value: 'T1059.001' },
{ key: 'description', label: 'Description', value: 'T9999 D3-CH' }
]);
const recognition = { runId: 'synthetic-reference-run', recognizerVersion: '1.0.0',
createdAt: '2026-09-26T00:00:00Z', references };
sheet.analysis.records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-occurrence-${index}`; });
sheet.analysis.records[0].referenceRecognition = recognition;
sheet.analysis.records[1].referenceRecognition = { ...recognition, references: [] };
const dataset = { sheets: [sheet], rawText: '', profileId: 'generic', scanTool: 'Synthetic' } as ImportedDataset;
const observation = { sourceKind: 'policy-observation', sourceResultId: 'synthetic-policy', projectId: 'synthetic',
assessmentId: 'slot', policyBackupInstanceId: 'synthetic-backup', title: 'Synthetic GPO setting',
importedAt: '2026-09-26T00:00:00Z', policyScope: 'computer', policyDeliveryMechanism: 'registry-policy',
policyIdentityStatus: 'resolved', policyIdentityKey: 'synthetic-setting', policyState: 'configured',
policyValues: [{ kind: 'registry-policy', operation: 'set-value', key: 'Software\\Synthetic', valueName: 'Mode',
registryType: 4, data: 0 }] } as unknown as UnifiedResultProjection;
const projected = appendPolicyObservationsToDataset(dataset, 'Synthetic', withGpos ? [observation] : [])!;
const records = projected.sheets[0].analysis.records;
expect(records).toHaveLength(withGpos ? 4 : 3);
expect(records[0].referenceRecognition).toEqual(recognition);
expect(records[1].referenceRecognition?.references).toEqual([]);
expect(records[2].referenceRecognition).toBeUndefined();
if (withGpos) expect(records[3].referenceRecognition).toBeUndefined();
const host = document.createElement('div'); document.body.append(host);
const root = createRoot(host);
try {
const record = records[0];
await act(async () => root.render(<FindingReader record={record} title={record.title} linkedSheets={[]}
resultReferences={[]} search="" position={0} recordCount={records.length}
onPrevious={() => {}} onNext={() => {}} onReview={() => {}}/>));
const section = host.querySelector('[aria-label="Catalog references"]')!;
expect(section).not.toBeNull();
expect(section.textContent).toContain('ATT&CK T1059.001');
expect(section.textContent).toContain('D3FEND D3-CH');
expect(section.textContent).toContain('Mentioned · Not found in this catalog');
expect(section.textContent).not.toContain('Not indexed yet');
const details = section.querySelector('details')!;
await act(async () => details.querySelector('summary')!.click());
expect(details.open).toBe(true);
expect(details.textContent).toContain('MITRE ATT&CK · attack');
expect(details.textContent).toContain('synthetic-reference-run');
expect(details.textContent).toContain(references[0].catalogVersion);
expect(details.textContent).toContain(references[0].catalogSha256);
} finally { await act(async () => root.unmount()); }
});

View File

@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest'; import { afterEach, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider'; import { useApp } from '../../src/app/AppProvider';
import { ObsidianGraphView } from '../../src/features/mitre-graph/ObsidianGraphView'; import { ObsidianGraphView } from '../../src/features/mitre-graph/ObsidianGraphView';
import { clearStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix } from '../../src/features/mitre-graph/mitre-graph';
import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records'; import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records';
import { makeSheet } from '../../src/importers/sheet-factory'; import { makeSheet } from '../../src/importers/sheet-factory';
@ -16,6 +17,7 @@ vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmark
vi.mock('../../src/features/search-filter/dynamic-facets', () => ({ deriveDynamicFacets: () => [] })); vi.mock('../../src/features/search-filter/dynamic-facets', () => ({ deriveDynamicFacets: () => [] }));
afterEach(() => { afterEach(() => {
clearStoredMitreMatrix();
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
Reflect.deleteProperty(Element.prototype, 'setPointerCapture'); Reflect.deleteProperty(Element.prototype, 'setPointerCapture');
@ -51,6 +53,18 @@ it('shows node text by default, keeps it on after selecting a node, and toggles
expect(textButton.getAttribute('aria-pressed')).toBe('true'); expect(textButton.getAttribute('aria-pressed')).toBe('true');
expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull(); expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull();
expect(container.querySelector('.obsidian-graph-cards')).toBeNull(); expect(container.querySelector('.obsidian-graph-cards')).toBeNull();
await act(async () => {
saveStoredMitreMatrix({ version: '19.2', tacticOrder: ['Command and Control'], techniques: [
{ id: 'T1001', name: 'Synthetic updated name', tactics: ['Command and Control'], platforms: [], parent: null, url: '', sourceCount: 0 }
], entities: [] });
window.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
});
expect(container.querySelector('.obsidian-node-technique')?.getAttribute('aria-label')).toContain('Synthetic updated name');
await act(async () => {
clearStoredMitreMatrix();
window.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
});
expect(container.querySelector('.obsidian-node-technique')?.getAttribute('aria-label')).toContain('Data Obfuscation');
expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Off'); expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Off');
expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Labels'); expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Labels');

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import type { ImportedDataset } from '../../src/domain/dataset';
import type { FindingRecord } from '../../src/domain/finding';
import { POLICY_EVALUATION_ENGINE, type PolicyEvaluationResult, type StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { appendPolicyObservationsToDataset } from '../../src/features/project-import/policy-observations-to-dataset';
import { makeSheet } from '../../src/importers/sheet-factory';
/** Properties the rebuilt project sheet recomputes from the extended row layout. */
const LAYOUT_KEYS = new Set(['id', 'index', 'title', 'fields', 'filled', 'meta', 'sourceFile', 'sourceSheet', 'highlighted']);
function policyInputs() {
const observation = {
sourceKind: 'policy-observation', sourceResultId: 'synthetic-observation', projectId: 'synthetic', assessmentId: 'slot',
policyBackupInstanceId: 'backup-1', title: 'Synthetic setting', importedAt: '2026-01-01T00:00:00Z',
policyScope: 'computer', policyDeliveryMechanism: 'registry-policy', policyIdentityStatus: 'resolved',
policyIdentityKey: 'synthetic-setting', policyValues: [{ kind: 'registry-policy', operation: 'set-value',
key: 'Software\\Policies\\Synthetic', valueName: 'Mode', typeCode: 4, data: { kind: 'uint32', value: 0 } }]
} as unknown as UnifiedResultProjection;
const comparison = {
id: 'synthetic-comparison', observationIds: [observation.sourceResultId], baselineId: 'baseline', baselineLabel: 'Synthetic baseline',
identity: { id: 'setting-synthetic', scope: 'computer', target: { kind: 'registry-value', key: 'Synthetic', valueName: 'Mode' } },
baselineState: 'explicit-recommendation', customerState: 'configured', classification: 'confirmed_configured_deviation', diagnosticCodes: [],
comparator: { version: 1, comparator: { kind: 'equality' }, relation: 'different',
baseline: { kind: 'integer', value: '1' }, actual: { kind: 'integer', value: '0' }, diagnosticCodes: [] }
} as unknown as PolicyEvaluationResult;
const evaluation = { run: { id: 'evaluation', projectId: 'synthetic', aggregationRunId: 'aggregation',
completedAt: '2026-01-01T00:00:00Z', evaluator: POLICY_EVALUATION_ENGINE }, results: [comparison] } as StoredPolicyEvaluation;
return { observation, evaluation };
}
describe('project sheet merge with GPO results', () => {
// Regression 2026-09-26: the merge copied an explicit list of properties, so newly added record metadata
// (CIS section titles, recognized references) silently disappeared in timeslots with GPO results.
it('keeps every non-layout property of a scanner record, including metadata unknown to the merge', () => {
const scannerSheet = makeSheet('Synthetic · All Records', [
['Title', 'Status', 'Source: Scan'],
['Synthetic scanner finding', 'Fail', 'CIS-CAT Pro · 2026-01-01T00:00:00.000Z']
], '', 'Synthetic', { isProjectSheet: true });
const original = scannerSheet.analysis.records[0];
Object.assign(original, {
findingOccurrenceId: 'occurrence-synthetic', findingDefinitionId: 'definition-synthetic',
scanRunId: 'run-synthetic', importFileId: 'file-synthetic',
benchmarkSectionTitles: new Map([['2.2', 'Synthetic User Rights Assignment']]),
referenceRecognition: { runId: 'reference-run', references: [] },
policyFailingBackupSources: [{ id: 'backup-synthetic', label: 'Synthetic backup' }],
policySourceEvidence: [], policyBaselineLabels: ['Synthetic baseline'], policyBaselineComparisons: [],
policyReviewMembers: [{ id: 'member-synthetic', baseline: 'Synthetic baseline' }],
// Stands for any record metadata added later without touching the merge.
syntheticFutureMetadata: { marker: 'survives' }
});
const dataset = { sheets: [scannerSheet], rawText: '', profileId: 'generic', scanTool: 'Multiple Tools' } as unknown as ImportedDataset;
const { observation, evaluation } = policyInputs();
const merged = appendPolicyObservationsToDataset(dataset, 'Synthetic', [observation], [evaluation])!;
const aggregate = merged.sheets[0];
const carried = aggregate.analysis.records[0] as FindingRecord & Record<string, unknown>;
const metadataKeys = Object.keys(original).filter(key => !LAYOUT_KEYS.has(key));
expect(metadataKeys).toContain('syntheticFutureMetadata');
for (const key of metadataKeys) expect(carried[key], key).toBe((original as unknown as Record<string, unknown>)[key]);
// Layout properties come from the rebuilt sheet: the GPO columns extend the scanner row.
const rebuilt = makeSheet(aggregate.name, aggregate.rows, '', 'Synthetic', { id: aggregate.id, isProjectSheet: true }).analysis.records[0];
expect(carried.id).toBe(rebuilt.id);
expect(carried.fields.map(field => field.label)).toEqual(aggregate.rows[0]);
expect(carried.fields.map(field => field.label)).toContain('Configured Value');
expect(carried.fields.find(field => field.label === 'Status')?.value).toBe('Fail');
expect(carried).not.toBe(original);
});
});

View File

@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { ReferenceRecognitionPanel } from '../../src/features/project-import/ReferenceRecognitionPanel';
import { FindingReferences } from '../../src/features/finding-reader/FindingReferences';
import { createReferenceRecognizer } from '../../src/domain/reference-recognition';
import { bundledReferenceCatalogs } from '../../server/reference-data/reference-catalogs';
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); });
it('recognizes the selected client on click and refreshes its workspace, retaining retry after failure', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const status = vi.spyOn(AssessorApiClient.prototype, 'getReferenceRecognitionStatus').mockResolvedValue({ totalScans: 1, indexedScans: 0, pendingScans: 1,
catalogs: bundledReferenceCatalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) });
const process = vi.spyOn(AssessorApiClient.prototype, 'recognizeReferences').mockRejectedValueOnce(new Error('Synthetic failure'))
.mockResolvedValue({ processedScans: 1, unchangedScans: 0, references: 2 });
const changed = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ReferenceRecognitionPanel projectId="synthetic-client" onChanged={changed}/>));
expect(host.textContent).toContain('ATT&CK 19.2 · D3FEND 1.6.0');
expect(host.querySelector('[role="status"]')?.textContent).toContain('Please update');
await act(async () => host.querySelector('button')!.click());
expect(host.querySelector('[role="alert"]')?.textContent).toContain('Synthetic failure');
expect(host.querySelector('button')!.disabled).toBe(false);
expect(host.querySelector('[role="status"]')?.textContent).toContain('Update failed — please retry');
status.mockResolvedValue({ totalScans: 1, indexedScans: 1, pendingScans: 0,
catalogs: bundledReferenceCatalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) });
await act(async () => host.querySelector('button')!.click());
expect(process).toHaveBeenLastCalledWith('synthetic-client');
expect(changed).toHaveBeenCalledOnce();
expect(host.querySelector('[role="status"]')?.textContent).toContain('Update successful');
expect(host.querySelector('[role="status"]')?.textContent).toContain('No action needed.');
expect(host.textContent).toContain('ATT&CK 19.2 · D3FEND 1.6.0');
} finally { await act(async () => root.unmount()); }
});
it.each([0, 1])('does not claim update success for an empty client or remaining work (scans=%i)', async totalScans => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.spyOn(AssessorApiClient.prototype, 'getReferenceRecognitionStatus').mockResolvedValue({ totalScans, indexedScans: 0, pendingScans: totalScans,
catalogs: bundledReferenceCatalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) });
const process = vi.spyOn(AssessorApiClient.prototype, 'recognizeReferences').mockResolvedValue({ processedScans: 1, unchangedScans: 0, references: 2 });
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ReferenceRecognitionPanel projectId="synthetic-client"/>));
if (totalScans === 0) {
expect(host.querySelector('[role="status"]')?.textContent).toBe('No scans to updateImport a scan first.');
expect(host.querySelector('button')!.disabled).toBe(true);
expect(process).not.toHaveBeenCalled();
expect(host.querySelector('details')!.open).toBe(false);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('neutral');
} else {
await act(async () => host.querySelector('button')!.click());
expect(host.querySelector('[role="status"]')?.textContent).toContain('Please update');
expect(host.textContent).not.toContain('Update successful');
expect(host.querySelector('button')!.dataset.attention).toBe('true');
expect(host.querySelector('details')!.open).toBe(true);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
}
expect(host.textContent).toContain('ATT&CK 19.2 · D3FEND 1.6.0');
} finally { await act(async () => root.unmount()); }
});
it('starts collapsed without a green signal while checking, then automatically opens when work is found', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
let resolveStatus!: (status: Awaited<ReturnType<AssessorApiClient['getReferenceRecognitionStatus']>>) => void;
vi.spyOn(AssessorApiClient.prototype, 'getReferenceRecognitionStatus').mockImplementation(() => new Promise(resolve => { resolveStatus = resolve; }));
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ReferenceRecognitionPanel projectId="synthetic-client"/>));
expect(host.querySelector('details')!.open).toBe(false);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('neutral');
expect(host.querySelector('summary')?.getAttribute('aria-label')).toContain('Checking status');
await act(async () => resolveStatus({ totalScans: 1, indexedScans: 0, pendingScans: 1, catalogs: [] }));
expect(host.querySelector('details')!.open).toBe(true);
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
expect(host.querySelector('[role="status"]')?.textContent).toContain('Please update');
} finally { await act(async () => root.unmount()); }
});
it('shows declared, mentioned and unknown references with inspectable source and frozen catalog version', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const references = createReferenceRecognizer(bundledReferenceCatalogs)([
{ key: 'attack', label: 'MITRE ATT&CK', value: 'T1059.001' }, { key: 'description', label: 'Description', value: 'T9999 D3-CH' }
]);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<FindingReferences record={{ id: 'synthetic', title: 'Synthetic', index: 0, fields: [], filled: [], meta: [],
referenceRecognition: { runId: 'synthetic-run', recognizerVersion: '1.0.0', createdAt: '2026-09-26T00:00:00Z', references } }}/>));
expect(host.textContent).toContain('Declared · Catalog match');
expect(host.textContent).toContain('Mentioned · Not found in this catalog');
await act(async () => host.querySelector('summary')!.click());
expect(host.querySelector('details')!.open).toBe(true);
expect(host.textContent).toContain('MITRE ATT&CK · attack');
expect(host.textContent).toContain('19.2');
expect(host.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer');
} finally { await act(async () => root.unmount()); }
});

View File

@ -5,6 +5,9 @@ import type { DatasetSheet } from '../../src/domain/dataset';
import type { FindingRecord } from '../../src/domain/finding'; import type { FindingRecord } from '../../src/domain/finding';
import { FindingReader } from '../../src/features/finding-reader/FindingReader'; import { FindingReader } from '../../src/features/finding-reader/FindingReader';
import { makeSheet } from '../../src/importers/sheet-factory'; import { makeSheet } from '../../src/importers/sheet-factory';
import { toolIdForRecord } from '../../src/importers/status-normalization';
import { powerShellDisplayLines } from '../../src/features/finding-reader/first-read';
import { cisSectionHeading } from '../../src/profiles/cis-cat/cis-benchmark-section-titles';
/** Synthetic scanner finding with the given retained fields. */ /** Synthetic scanner finding with the given retained fields. */
function record(fields: Array<[string, string]>): FindingRecord { function record(fields: Array<[string, string]>): FindingRecord {
@ -52,11 +55,48 @@ describe('scanner first read', () => {
const { container } = render(item, 'pingcastle'); const { container } = render(item, 'pingcastle');
const step = container.querySelector('.finding-first-read.policy-next-step')!; const step = container.querySelector('.finding-first-read.policy-next-step')!;
expect(step.querySelector('p')?.textContent).toBe('Upgrade the workstations. You can check the OS with the following PowerShell command:'); expect(step.querySelector('p')?.textContent).toBe('Upgrade the workstations. You can check the OS with the following PowerShell command:');
expect(step.querySelector('pre.finding-first-read__command code')?.textContent).toBe(command); expect(step.querySelector('pre.finding-first-read__command code')?.textContent).toBe(
'Get-ADComputer -Filter {(Enabled -eq $true) -and (OperatingSystem -Like "*Windows 7*")} -Property Name,OperatingSystem |\n'
+ ' format-table Name,OperatingSystem -Wrap -Auto');
expect(step.querySelector('details')?.textContent).toContain('Confirm the resulting inventory.'); expect(step.querySelector('details')?.textContent).toContain('Confirm the resulting inventory.');
expect(item.fields[0].value).toBe(`${advice}\n\nConfirm the resulting inventory.`); expect(item.fields[0].value).toBe(`${advice}\n\nConfirm the resulting inventory.`);
}); });
// Regression 2026-09-26: a script introduced by "the following script to ...:" and starting with a
// variable assignment stayed inside the prose instead of its own command block.
it('separates an introduced PowerShell script that starts with a variable assignment', () => {
const script = '$SyntheticSid = New-Object System.Security.Principal.SecurityIdentifier ([System.Security.Principal.WellKnownSidType]::AccountDomainUsersSid,(Get-ADDomain).DomainSID) '
+ 'Get-ADUser -Filter * -Properties PrimaryGroup | Where-Object { $_.PrimaryGroup -ne (Get-ADGroup -Filter {SID -eq $SyntheticSid}).DistinguishedName } | Select-Object UserPrincipalName,PrimaryGroup';
const lead = 'Restore the synthetic default. You can use the following script to list users with a synthetic primary group:';
const item = record([['Advised solution', `${lead} ${script}`]]);
const { container } = render(item, 'pingcastle');
const step = container.querySelector('.finding-first-read.policy-next-step')!;
expect(step.querySelector('p')?.textContent).toBe(lead);
const shown = step.querySelector('pre.finding-first-read__command code')?.textContent ?? '';
expect(shown.split('\n')).toEqual([
'$SyntheticSid = New-Object System.Security.Principal.SecurityIdentifier ([System.Security.Principal.WellKnownSidType]::AccountDomainUsersSid,(Get-ADDomain).DomainSID)',
'Get-ADUser -Filter * -Properties PrimaryGroup |',
' Where-Object { $_.PrimaryGroup -ne (Get-ADGroup -Filter {SID -eq $SyntheticSid}).DistinguishedName } |',
' Select-Object UserPrincipalName,PrimaryGroup']);
// Only whitespace changes; every other character is retained.
expect(shown.replace(/\s+/g, ' ')).toBe(script);
expect(item.fields[0].value).toBe(`${lead} ${script}`);
});
it('lays out a flattened command only at unambiguous structure boundaries', () => {
expect(powerShellDisplayLines('Get-Help Get-ADUser -Full')).toEqual(['Get-Help Get-ADUser -Full']);
expect(powerShellDisplayLines('Write-Output "a | b" | Out-Null')).toEqual(['Write-Output "a | b" |', ' Out-Null']);
expect(powerShellDisplayLines('Test-Path $a || Write-Output x')).toEqual(['Test-Path $a || Write-Output x']);
expect(powerShellDisplayLines('Get-A |\n Get-B')).toEqual(['Get-A |', ' Get-B']);
});
it('keeps prose with a colon but no command in the text', () => {
const advice = 'Review the script policy: re-run only signed scripts.';
const { container } = render(record([['Advised solution', advice]]), 'pingcastle');
expect(container.querySelector('pre.finding-first-read__command')).toBeNull();
expect(container.querySelector('.policy-next-step')?.textContent).toContain(advice);
});
it('Purple Knight: result message, affected count and likelihood as the problem, remediation as the next step', () => { it('Purple Knight: result message, affected count and likelihood as the problem, remediation as the next step', () => {
const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'], const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'],
['Likelihood of compromise', 'Synthetic likelihood.'], ['Result message', 'Found 2 synthetic objects.'], ['Number of results', '2'], ['Likelihood of compromise', 'Synthetic likelihood.'], ['Result message', 'Found 2 synthetic objects.'], ['Number of results', '2'],
@ -91,6 +131,108 @@ describe('scanner first read', () => {
expect(link.getAttribute('rel')).toBe('noopener noreferrer'); expect(link.getAttribute('rel')).toBe('noopener noreferrer');
}); });
// 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.
it.each(['Purple Knight for Active Directory', 'Purple Knight for Entra ID', 'Purple Knight for Active Directory and Entra ID'])(
'Purple Knight in mixed Work (%s): opening the report shows Problem and Next step', runName => {
const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'],
['Likelihood of compromise', 'Synthetic likelihood.'], ['Result message', 'Found 2 synthetic objects.'], ['Number of results', '2'],
['Remediation', 'Remove the synthetic permission.'], ['Source: Scan', `${runName} · 2026-01-01T00:00:00.000Z`]]);
expect(toolIdForRecord(item, 'generic')).toBe('purple-knight');
const { container, values } = render(item, 'generic');
expect(values('.finding-first-read')).toEqual({ Result: 'Found 2 synthetic objects.', Affected: '2', Impact: 'Synthetic likelihood.' });
expect(container.querySelector('.policy-next-step')?.textContent).toContain('Remove the synthetic permission.');
expect(headings(container)).not.toContain('Remediation');
expect(headings(container)).not.toContain('Result message');
});
// Regression 2026-09-26: Purple Knight exports append the reference titles to the likelihood text and lose
// the paragraph breaks, so the Impact row ended in ">ReferencesNIST.SP.800-63-3...".
it('Purple Knight: impact ends before the appended references, which move to the references position', () => {
const likelihood = 'Synthetic weak passwords are easier to crack.The risk is higher for synthetic privileged accounts.'
+ '>ReferencesNIST.SP.800-63-3Synthetic weak password policy | ANSSI';
const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'],
['Likelihood of compromise', likelihood], ['Result message', 'Found 5 synthetic users.'], ['Number of results', '5'],
['Remediation', 'Apply the synthetic policy.'], ['Source: Scan', 'Purple Knight for Active Directory · 2026-01-01T00:00:00.000Z']]);
const { container } = render(item, 'generic');
const impact = [...container.querySelectorAll('.finding-first-read .policy-problem__values > div')]
.find(row => row.querySelector('dt')?.textContent === 'Impact')!.querySelector('dd')!;
expect(impact.textContent).not.toContain('References');
// The reader paragraph keeps line breaks (white-space: pre-wrap), so the restored break shows as a new paragraph.
expect(impact.textContent!.split('\n\n')).toEqual([
'Synthetic weak passwords are easier to crack.', 'The risk is higher for synthetic privileged accounts.']);
expect(headings(container)).toEqual(['Description', 'References']);
expect(container.querySelector('.reader-summary')!.textContent).toContain('NIST.SP.800-63-3Synthetic weak password policy | ANSSI');
expect(container.querySelector('.reader-summary')!.textContent!.split('Synthetic weak password policy').length).toBe(2);
expect(item.fields.find(field => field.label === 'Likelihood of compromise')?.value).toBe(likelihood);
});
it.each([
['a spaced heading', 'Synthetic impact text.References Synthetic reference title', 'Synthetic impact text.', 'Synthetic reference title'],
['a heading glued to the last word', 'Synthetic impact is resolvedReferencesSynthetic reference title', 'Synthetic impact is resolved', 'Synthetic reference title'],
['an ordinary mention', 'Open the References tab in the synthetic console.', 'Open the References tab in the synthetic console.', undefined]
])('Purple Knight impact with %s', (_case, likelihood, impact, references) => {
const item = record([['Name', 'Synthetic finding'], ['Likelihood of compromise', likelihood]]);
const { container } = render(item, 'purple-knight');
const row = [...container.querySelectorAll('.finding-first-read .policy-problem__values > div')].find(item => item.querySelector('dt')?.textContent === 'Impact');
expect(row?.querySelector('dd')?.textContent).toBe(impact);
expect(headings(container).includes('References')).toBe(Boolean(references));
if (references) expect(container.querySelector('.reader-summary')!.textContent).toContain(references);
});
it('Maester without a remediation field: the native Remediation action section becomes the next step, shown once', () => {
const description = 'Synthetic background paragraph.\n\n#### Remediation action:\n\nOpen the synthetic portal.\n\n#### Related links\n\n* Synthetic link';
const item = record([['ID', 'MT.9003'], ['Title', 'Synthetic finding'], ['Status', 'Failed'], ['Description', description],
['Source: Scan', 'Maester · 2026-01-01T00:00:00.000Z']]);
const { container } = render(item, 'generic');
const step = container.querySelector('.finding-first-read.policy-next-step')!;
expect(step.querySelector('p')?.textContent).toBe('Open the synthetic portal.');
expect(step.querySelector('details')?.textContent).toContain('Synthetic link');
const summary = container.querySelector('.reader-summary')!.textContent!;
expect(summary).toContain('Synthetic background paragraph.');
expect(summary.split('Open the synthetic portal.').length).toBe(2);
expect(summary).not.toContain('Remediation action');
expect(item.fields.find(field => field.label === 'Description')?.value).toBe(description);
});
it('Maester export with flattened line breaks still separates the native section at its heading', () => {
const item = record([['ID', 'MT.9004'], ['Title', 'Synthetic finding'], ['Status', 'Failed'],
['Description', 'Synthetic background. #### Remediation action: 1. Open the synthetic portal. 2. Save.']]);
const { container } = render(item, 'maester');
const step = container.querySelector('.policy-next-step')!.textContent!;
expect(step).toContain('Open the synthetic portal.');
expect(step).toContain('Save.');
const summary = container.querySelector('.reader-summary')!.textContent!;
expect(summary).toContain('Synthetic background.');
expect(summary.split('Open the synthetic portal.').length).toBe(2);
});
it('Maester without a native remediation section keeps the full description and shows no next step', () => {
const item = record([['ID', 'MT.9005'], ['Title', 'Synthetic finding'], ['Status', 'Failed'], ['Description', 'Only synthetic background.']]);
const { container } = render(item, 'maester');
expect(container.querySelector('.policy-next-step')).toBeNull();
expect(container.querySelector('.reader-summary')!.textContent).toContain('Only synthetic background.');
});
it('CIS-CAT report: each number badge names its report section on hover', () => {
const item: FindingRecord = { ...record([['Rule ID', '2.2.2'], ['Benchmark Item', '2.2.2 Ensure a synthetic user right'], ['Result', 'Fail']]),
benchmarkSectionTitles: new Map([['2', 'Synthetic Local Policies'], ['2.2', 'Synthetic User Rights Assignment']]) };
const badges = [...render(item, 'cis-cat').container.querySelectorAll('[data-derived-category="cis"]')];
expect(badges.map(badge => badge.textContent)).toEqual(['CIS 2', 'CIS 2.2']);
expect(badges.map(badge => badge.getAttribute('title'))).toEqual(['Synthetic Local Policies', 'Synthetic User Rights Assignment']);
});
it.each([
['2.2.2', '2.2.2 Ensure a synthetic user right', 'Synthetic User Rights Assignment: Ensure a synthetic user right'],
['9.9.9', '9.9.9 Ensure a synthetic unknown section', '9.9.9 Ensure a synthetic unknown section'],
['2.2.2', 'Ensure a heading without number', 'Ensure a heading without number']
])('CIS heading for rule %s: %s', (ruleId, heading, expected) => {
const titles = { benchmarkSectionTitles: new Map([['2.2', 'Synthetic User Rights Assignment']]) };
expect(cisSectionHeading(titles, ruleId, heading)).toBe(expected);
expect(cisSectionHeading({}, ruleId, heading)).toBe(heading);
});
it('never offers a non-https documentation link from imported content', () => { it('never offers a non-https documentation link from imported content', () => {
const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['HelpUrl', 'javascript:alert(1)']]); const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['HelpUrl', 'javascript:alert(1)']]);
expect(render(item, 'maester').container.querySelector('.policy-next-step')).toBeNull(); expect(render(item, 'maester').container.querySelector('.policy-next-step')).toBeNull();

View File

@ -117,6 +117,19 @@ describe('tileBadges', () => {
.toEqual(['CIS 2', 'CIS 2.2']); .toEqual(['CIS 2', 'CIS 2.2']);
}); });
it('keeps the CIS number on the tile and offers the run report section title on hover', () => {
const cisRecord: FindingRecord = {
...record,
title: '2.2.2 Ensure a synthetic user right',
fields: [{ index: 0, label: 'Rule ID', value: '2.2.2' }],
benchmarkSectionTitles: new Map([['2', 'Synthetic Local Policies'], ['2.2', 'Synthetic User Rights Assignment']])
};
expect(tileBadges(cisRecord, 'cis-cat').filter(badge => badge.label === 'CIS Category')).toEqual([
{ label: 'CIS Category', value: 'CIS 2', text: 'CIS 2', title: 'Synthetic Local Policies' },
{ label: 'CIS Category', value: 'CIS 2.2', text: 'CIS 2.2', title: 'Synthetic User Rights Assignment' }
]);
});
it('ordnet abgeleitete Kategorien vor Severity, Weight und Score ein', () => { it('ordnet abgeleitete Kategorien vor Severity, Weight und Score ein', () => {
const cisRecord: FindingRecord = { ...record, title: '2.2.2 Ensure a synthetic user right' }; const cisRecord: FindingRecord = { ...record, title: '2.2.2 Ensure a synthetic user right' };

48
tests/fixtures/client-info-facts.ts vendored Normal file
View File

@ -0,0 +1,48 @@
import type { EnvironmentSnapshot, EnvironmentObservation, ScannerReportSection } from '../../src/domain/environment-context';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
const tool = { id: 'synthetic', name: 'Synthetic scanner' };
const observation = (property: string, value: string | number): EnvironmentObservation => ({
version: 1, id: property, scanRunId: 'synthetic-run', importFileId: 'synthetic-file', tool,
subject: { type: 'directory' }, property, label: property, originalValue: value, normalizedValue: value,
valueType: typeof value === 'number' ? 'number' : 'string', state: 'observed',
source: { rowIndex: 0 }, mappingVersion: '1', attributes: {}
});
const section = (sectionKey: string, name: string, headers: string[], rows: (string | number)[][]): ScannerReportSection => ({
version: 1, id: sectionKey, scanRunId: 'synthetic-run', importFileId: 'synthetic-file', tool,
sectionKey, label: name, ordinal: 0, state: 'observed', tables: [{ name, headers, rows }],
source: { rowIndex: 0 }, extractorVersion: '1', attributes: {}
});
export const clientFactsSnapshot: EnvironmentSnapshot = {
version: 1, projectId: 'synthetic-project', scanRunIds: ['synthetic-run'], generatedAt: '2026-01-15T00:00:00Z',
coverage: { observed: 11, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 },
observations: [
observation('domain.fqdn', 'contoso.example'), observation('forest.name', 'contoso.example'),
observation('domain.netbios_name', 'CONTOSO'), observation('domain.count', 1),
observation('domain.controller_count', 2), observation('domain.functional_level', 'Windows Server 2016'),
observation('forest.functional_level', 'Windows Server 2016'), observation('forest.schema_version', '87'),
observation('domain.created_at', '2020-01-15'), observation('entra.tenant_name', 'Synthetic tenant'),
observation('host.name', 'Synthetic host')
],
sections: [
section('sectionDomainInformation', 'Directory evidence', ['Domain', 'Forest'], [['contoso.example', 'contoso.example']]),
section('sectionUserInformation', 'Account analysis', ['Nb User Accounts', 'Nb Enabled', 'Nb Disabled', 'Nb pwd never Expire'], [[16, 15, 1, 5]]),
section('sectionComputerInformation', 'Computer analysis', ['Nb Computer Accounts'], [[24]])
]
};
export function clientFactsState() {
const sheet = makeSheet('Synthetic layout', [
['Name', 'Status', 'Severity', 'Source: Scan'], ['Synthetic finding', 'Failed', 'High', 'Scanner A']
], '', 'synthetic.csv', { isProjectSheet: true });
return {
dataset: { fileName: 'Synthetic layout', sheets: [sheet], scanTool: 'Generic' },
activeSheet: sheet, activeProfile: genericProfile, reviews: {}, facetFilters: {},
targetProjectId: 'synthetic-project', activeScanRunIds: ['synthetic-run'], activeAssessmentIds: ['synthetic-assessment'],
backendStatus: 'unavailable', projects: [], assessments: [], selectedProject: null,
setStatusFilter() {}, setFacetFilter() {}, setPathFindingIds() {}, resetFilters() {},
setView() {}, setActionOnly() {}, setActiveSheet() {}
};
}

23
tests/fixtures/reference-recognition.ts vendored Normal file
View File

@ -0,0 +1,23 @@
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
export async function seedReferenceScan(repository: AssessorRepository, prefix = 'synthetic-reference') {
const at = '2026-09-26T00:00:00.000Z';
const project = { id: `${prefix}-project`, name: 'Synthetic reference client', createdAt: at, updatedAt: at, attributes: {} };
const assessment = { id: `${prefix}-assessment`, projectId: project.id, name: 'Synthetic timeslot', createdAt: at, updatedAt: at };
const scan = { id: `${prefix}-scan`, assessmentId: assessment.id, sequence: 1, label: 'Synthetic scan', tool: { id: 'generic', name: 'Synthetic scanner' }, importedAt: at };
const provenance = { decisionSource: 'source-native' as const, confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] }, mappingVersion: '1.0.0' };
await repository.saveProject(project);
await repository.saveAssessment(assessment);
const session = await repository.beginImport({ sessionId: prefix, projectId: project.id, assessmentId: assessment.id, scanRun: scan });
await session.stage({
importFiles: [{ id: `${prefix}-file`, scanRunId: scan.id, fileName: 'synthetic.csv', format: 'CSV', sizeBytes: 100, sha256: 'a'.repeat(64), importedAt: at,
detectedTool: scan.tool, detection: provenance.confidence, processedWith: { parserId: 'csv', parserVersion: '1.0.0', profileId: 'generic', profileVersion: '1.0.0', mappingVersion: '1.0.0' }, sourceArtifact: { kind: 'not-retained', reason: 'Synthetic' } }],
findingDefinitions: [{ id: `${prefix}-definition`, canonicalKey: `${prefix}:RULE1`, title: 'Synthetic reference finding', frameworkReferences: [], createdAt: at, updatedAt: at }],
findingOccurrences: [{ id: `${prefix}-occurrence`, definitionId: `${prefix}-definition`, scanRunId: scan.id, importFileId: `${prefix}-file`,
sourceIdentity: { tool: scan.tool, originalRuleId: 'RULE1' }, sourcePosition: { rowIndex: 1 },
originalFields: [{ key: 'attack', label: 'MITRE ATT&CK', value: 'T1059.001' }, { key: 'defense', label: 'MITRE D3FEND', value: 'D3-CH' },
{ key: 'text', label: 'Description', value: 'Consider T1003; unknown T9999.' }], fieldMappings: [],
severity: { originalValue: 'high', normalizedValue: 'high', provenance }, status: { originalValue: 'Open', normalizedValue: 'open', provenance } }]
});
await session.commit();
return { project, assessment, scan, model: (await repository.loadAssessmentModel(assessment.id))! };
}

View File

@ -0,0 +1,68 @@
import { randomUUID } from 'node:crypto';
import { Pool } from 'pg';
import { expect, it } from 'vitest';
import { repositoryConnectionString } from '../../server/repository-factory';
import { runDatabaseMigrations } from '../../server/postgres/migration-runner';
import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository';
import { ReferenceRecognitionService } from '../../server/reference-data/reference-recognition-service';
import { bundledReferenceCatalogs } from '../../server/reference-data/reference-catalogs';
import { seedReferenceScan } from '../fixtures/reference-recognition';
// Opt-in after a verified backup. Uses ONLY the canonical DB, with isolated synthetic IDs.
it.skipIf(process.env.ASSESSOR_REFERENCE_POSTGRES_TEST !== '1')('persists reference history and catalog snapshots across repository restarts with exact-scope cleanup', async () => {
process.loadEnvFile('.env.postgres');
const connectionString = repositoryConnectionString({
ASSESSOR_DATABASE_URL: process.env.ASSESSOR_DATABASE_URL,
ASSESSOR_POSTGRES_HOST: process.env.ASSESSOR_POSTGRES_HOST,
ASSESSOR_POSTGRES_PORT: process.env.ASSESSOR_POSTGRES_PORT,
ASSESSOR_POSTGRES_DB: process.env.ASSESSOR_POSTGRES_DB,
ASSESSOR_POSTGRES_USER: process.env.ASSESSOR_POSTGRES_USER,
ASSESSOR_POSTGRES_PASSWORD: process.env.ASSESSOR_POSTGRES_PASSWORD
});
if (!connectionString) throw new Error('Canonical database configuration required.');
const address = new URL(connectionString);
if (address.hostname !== '127.0.0.1' || address.port !== '5432' || address.pathname !== '/assessor') throw new Error('Only the canonical datastore is permitted.');
const pool = new Pool({ connectionString, max: 2 });
const prefix = `synthetic-reference-${randomUUID()}`;
const projectId = `${prefix}-project`;
try {
await runDatabaseMigrations(pool);
const repository = await PostgresAssessorRepository.create(pool);
const { assessment, model } = await seedReferenceScan(repository, prefix);
const service = new ReferenceRecognitionService(repository);
await service.reprocess(projectId);
const revision = repository.revision;
await service.reprocess(projectId);
expect(repository.revision).toBe(revision);
const restarted = await PostgresAssessorRepository.create(pool);
const stored = (await restarted.loadAssessmentModel(assessment.id))!;
expect(stored.findingOccurrences).toEqual(model.findingOccurrences);
expect(stored.referenceRuns).toHaveLength(1);
expect(stored.referenceRuns![0].results[0].references).toHaveLength(4);
const exported = await restarted.exportProject(projectId);
expect(exported.referenceCatalogs.map(catalog => catalog.sha256).sort()).toEqual(bundledReferenceCatalogs.map(catalog => catalog.sha256).sort());
expect(exported.referenceRuns).toEqual(stored.referenceRuns);
// Superseding a scan must not orphan the source evidence of exported reference history.
const successor = { ...model.scanRuns[0], id: `${prefix}-successor`, sequence: 2, importedAt: '2026-09-26T00:30:00.000Z' };
const replacementFile = { ...model.importFiles[0], id: `${prefix}-replacement-file`, scanRunId: successor.id };
const replacementOccurrence = { ...model.findingOccurrences[0], id: `${prefix}-replacement-occurrence`, scanRunId: successor.id, importFileId: replacementFile.id };
const replacement = await restarted.beginImport({ sessionId: `${prefix}-replacement`, projectId, assessmentId: assessment.id, scanRun: successor });
await replacement.stage({ importFiles: [replacementFile], findingDefinitions: model.findingDefinitions, findingOccurrences: [replacementOccurrence] });
await replacement.commit();
await restarted.adoptScanRunReplacement(projectId, model.scanRuns[0].id, successor.id, '2026-09-26T01:00:00.000Z');
const withHistory = await restarted.exportProject(projectId);
expect(withHistory.scanRuns.map(run => run.id)).toContain(model.scanRuns[0].id);
expect(withHistory.findingOccurrences.map(item => item.id)).toContain(stored.referenceRuns![0].results[0].occurrenceId);
expect(withHistory.importFiles.map(item => item.id)).toContain(stored.referenceRuns![0].results[0].importFileId);
await expect(restarted.saveReferenceRun({ ...stored.referenceRuns![0], projectId: 'another-client', id: `${prefix}-foreign` }, bundledReferenceCatalogs)).rejects.toThrow();
expect(await restarted.listReferenceRuns('another-client')).toEqual([]);
await restarted.deleteProject(projectId, { projectId, cascade: true });
expect(await restarted.listReferenceRuns(projectId)).toEqual([]);
expect(await restarted.listReferenceCatalogs(projectId)).toEqual([]);
} finally {
// This ID was generated in this test; no unrelated project is queried or deleted.
const repository = await PostgresAssessorRepository.create(pool);
if (await repository.getProject(projectId)) await repository.deleteProject(projectId, { projectId, cascade: true });
await pool.end();
}
}, 30_000);

View File

@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`); const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({ expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.6.46', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' status: 'ok', application: 'ASSESSOR', productVersion: '2.8.7', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
}); });
expect(health.headers.get('access-control-allow-origin')).toBeNull(); expect(health.headers.get('access-control-allow-origin')).toBeNull();
}); });

View File

@ -40,6 +40,27 @@ describe('batch import HTTP workflow', () => {
const mutate = (path: string, body: unknown) => fetch(`${baseUrl}${path}`, { const mutate = (path: string, body: unknown) => fetch(`${baseUrl}${path}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1' }, body: JSON.stringify(body) method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1' }, body: JSON.stringify(body)
}); });
it('automatically persists reference provenance on import and guards explicit idempotent reprocessing', async () => {
const bytes = Buffer.from('Rule ID;Title;Severity;Status;MITRE ATT&CK;MITRE D3FEND;Description\nSYN-REF;Synthetic reference;High;Failed;T1059.001;D3-CH;Consider T1003');
const file = { fileName: 'synthetic-references.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file] })).json() as ImportBatchPreview;
const entry = preview.entries[0];
const response = await mutate('/api/v1/import-batches/complete', { batchId: preview.batchId,
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }] });
expect(response.status).toBe(200);
const complete = await response.json() as ImportBatchCompleteResponse;
const model = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
expect(model.referenceRuns).toHaveLength(1);
expect(model.referenceRuns![0].results[0].references.map(ref => ref.id)).toEqual(['T1059.001', 'D3-CH', 'T1003']);
expect(model.findingDefinitions[0].frameworkReferences).toEqual([]);
const path = `/api/v1/projects/${complete.projectId}/references`;
expect(await (await fetch(`${baseUrl}${path}`)).json()).toMatchObject({ totalScans: 1, indexedScans: 1, pendingScans: 0 });
expect((await fetch(`${baseUrl}${path}/recognize`, { method: 'POST', body: '{}' })).status).toBe(403);
expect(await (await mutate(`${path}/recognize`, {})).json()).toMatchObject({ processedScans: 0, unchangedScans: 1 });
expect((await mutate('/api/v1/projects/foreign-client/references/recognize', {})).status).toBe(404);
const reloaded = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
expect(reloaded).toEqual(model);
});
it('records risk acceptance through the protected API and preserves it on reload and ordinary review updates', async () => { it('records risk acceptance through the protected API and preserves it on reload and ordinary review updates', async () => {
const bytes = Buffer.from('Rule ID;Title;Severity;Status\nSYN-1;Synthetic risk;High;Failed'); const bytes = Buffer.from('Rule ID;Title;Severity;Status\nSYN-1;Synthetic risk;High;Failed');
const file = { fileName: 'synthetic.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' }; const file = { fileName: 'synthetic.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };

View File

@ -0,0 +1,84 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, expect, it, vi } from 'vitest';
import { MitreCatalogService } from '../../server/reference-data/mitre-catalog-service';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import type { AddressInfo } from 'node:net';
const stix = (version: string) => ({
type: 'bundle', objects: [
{ type: 'x-mitre-collection', name: 'Enterprise ATT&CK', x_mitre_version: version },
{ type: 'x-mitre-matrix', name: 'Enterprise ATT&CK', tactic_refs: ['x-mitre-tactic--1'] },
{ id: 'x-mitre-tactic--1', type: 'x-mitre-tactic', name: 'Execution', x_mitre_shortname: 'execution' },
{ id: 'attack-pattern--1', type: 'attack-pattern', name: 'Example technique',
external_references: [{ source_name: 'mitre-attack', external_id: 'T1234' }],
kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }] }
]
});
let directory: string | undefined;
afterEach(async () => { if (directory) await rm(directory, { recursive: true, force: true }); directory = undefined; });
it('updates the shared catalog only for a newer valid MITRE version and persists it', async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-mitre-'));
const path = join(directory, 'reference-data', 'enterprise.json');
let version = '19.3';
const fetcher = vi.fn(async (url: string) => {
expect(url).toBe('https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json');
return new Response(JSON.stringify(stix(version)), { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as unknown as typeof fetch;
const service = new MitreCatalogService(path, fetcher);
expect((await service.current()).catalog.version).toBe('19.2');
const source = await service.officialSource();
expect(JSON.parse(source.bytes.toString('utf8'))).toEqual(stix('19.3'));
expect((await service.current()).catalog.version).toBe('19.2');
expect(await service.refresh()).toMatchObject({ updated: true, origin: 'mitre', catalog: { version: '19.3' } });
expect((await new MitreCatalogService(path, fetcher).current()).catalog.version).toBe('19.3');
version = '19.2';
expect(await service.refresh()).toMatchObject({ updated: false, catalog: { version: '19.3' } });
expect((await service.current()).catalog.version).toBe('19.3');
});
it('keeps the active catalog when MITRE returns an invalid or oversized file', async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-mitre-'));
const path = join(directory, 'reference-data', 'enterprise.json');
const invalid = new MitreCatalogService(path, async () => new Response('{', { status: 200 }));
await expect(invalid.refresh()).rejects.toMatchObject({ code: 'mitre_source_invalid' });
const oversized = new MitreCatalogService(path, async () => new Response('{}', {
status: 200, headers: { 'Content-Length': String(81 * 1024 * 1024) }
}));
await expect(oversized.refresh()).rejects.toMatchObject({ code: 'mitre_source_too_large' });
expect((await invalid.current()).catalog.version).toBe('19.2');
});
it('serves the original MITRE download separately from the active catalog and protects refresh', async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-mitre-'));
const original = JSON.stringify(stix('19.3'));
const service = new MitreCatalogService(join(directory, 'reference-data', 'enterprise.json'),
async () => new Response(original, { status: 200 }));
const server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory!, mitreCatalogService: service });
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/reference-data/mitre-attack`;
const download = await fetch(`${base}/source`);
expect(download.status).toBe(200);
expect(download.headers.get('content-disposition')).toBe('attachment; filename="enterprise-attack-v19.3.json"');
expect(await download.text()).toBe(original);
expect((await (await fetch(base)).json()).catalog.version).toBe('19.2');
const blocked = await fetch(`${base}/refresh`, { method: 'POST', body: '{}' });
expect(blocked.status).toBe(403);
const updated = await fetch(`${base}/refresh`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: new URL(base).origin }, body: '{}'
});
expect(updated.status).toBe(200);
expect(await updated.json()).toMatchObject({ updated: true, catalog: { version: '19.3' } });
expect((await (await fetch(base)).json()).catalog.version).toBe('19.3');
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
});

View File

@ -0,0 +1,46 @@
import { expect, it } from 'vitest';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { ReferenceRecognitionService } from '../../server/reference-data/reference-recognition-service';
import { bundledReferenceCatalogs, referenceSnapshot } from '../../server/reference-data/reference-catalogs';
import { seedReferenceScan } from '../fixtures/reference-recognition';
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
import { buildFindingPathItems, defaultMitreGraphData } from '../../src/features/mitre-graph/mitre-graph';
it('reprocesses retained fields, persists provenance and freezes catalog mappings until explicitly reprocessed', async () => {
const repository = new LocalAssessorRepository();
const { project, assessment, model } = await seedReferenceScan(repository);
let catalogs = bundledReferenceCatalogs;
const service = new ReferenceRecognitionService(repository, async () => catalogs);
expect(await service.status(project.id)).toMatchObject({ totalScans: 1, indexedScans: 0, pendingScans: 1 });
expect(await service.reprocess(project.id)).toEqual({ processedScans: 1, unchangedScans: 0, references: 4 });
const revision = repository.revision;
expect(await service.reprocess(project.id)).toMatchObject({ processedScans: 0, unchangedScans: 1 });
expect(repository.revision).toBe(revision);
const stored = (await repository.loadAssessmentModel(assessment.id))!;
expect(stored.findingOccurrences).toEqual(model.findingOccurrences);
expect(stored.findingDefinitions).toEqual(model.findingDefinitions);
expect(stored.reviews).toEqual(model.reviews);
const record = canonicalModelToDataset(stored)!.sheets[0].analysis.records[0];
const path = buildFindingPathItems([record], defaultMitreGraphData)[0];
expect(path.techniqueIds).toEqual(['T1059.001', 'T1003']);
expect(path.defenses).toEqual(['D3-CH · Credential Hardening']);
expect(path.mappingOrigin).toBe('native');
expect(record.referenceRecognition?.references[0].catalogSha256).toBe(catalogs[0].sha256);
const { sha256: _, ...attack } = catalogs[0];
catalogs = [referenceSnapshot({ ...attack, version: '99.0', entries: attack.entries.map(entry => entry.id === 'T1059.001' ? { ...entry, tactics: ['Synthetic future tactic'] } : entry) }), catalogs[1]];
expect(await service.status(project.id)).toMatchObject({ indexedScans: 1, pendingScans: 1 });
expect(buildFindingPathItems([record], { ...defaultMitreGraphData, techniques: [] })[0].tactics).toContain('Execution');
await service.reprocess(project.id);
expect(await repository.listReferenceRuns(project.id)).toHaveLength(2);
const exported = await repository.exportProject(project.id);
expect(exported.referenceCatalogs).toHaveLength(3);
expect(exported.referenceRuns).toHaveLength(2);
expect(exported.referenceRuns[0]).toEqual(stored.referenceRuns![0]);
expect(await repository.listReferenceRuns('another-client')).toEqual([]);
expect(await repository.listReferenceCatalogs('another-client')).toEqual([]);
await expect(repository.saveReferenceRun({ ...stored.referenceRuns![0], id: 'foreign', projectId: 'another-client' }, bundledReferenceCatalogs)).rejects.toThrow();
await expect(repository.saveReferenceRun({ ...stored.referenceRuns![0], inputSha256: 'b'.repeat(64) }, bundledReferenceCatalogs)).rejects.toThrow('unveränderlich');
await repository.deleteProject(project.id, { projectId: project.id, cascade: true });
expect(await repository.listReferenceRuns(project.id)).toEqual([]);
expect(await repository.listReferenceCatalogs(project.id)).toEqual([]);
});