feat(compliance): direct BSI mapping, ISO/BSI review and hybrid search

Release 2.23.0 (Phase 13F, BSI Grundschutz++ stage 2).

- Map rules directly to BSI Kernel requirements from a dedicated BSI
  corpus in Qdrant; mappings and decisions carry a framework
  (migration 029, verified backup before applying).
- Compare directly accepted BSI requirements with those derived from
  accepted ISO controls (Consistent / Inconsistent / No ISO reference)
  in a new "ISO <-> BSI review" tile in the Client Info Compliance tab,
  with Accept/Reject and "Mark reviewed" per fingerprint.
- Hybrid retrieval for ISO and BSI: German restatement of the public
  query, dense English + dense German + German BM25 fused by
  reciprocal rank; BSI skips BES/DLS/GEB/PERS and sends 16 candidates.
- ISO prompt 1.3.0 gives the model the full ISO/IEC 27002 control text
  (never sent to the browser).
- Auto-accept only the first two strong proposals (primary, high or
  medium confidence).
- Recognize Purple Knight scoped tool ids and SI indicator ids in the
  mapping query.
- Rename Result DB "Map all ISO controls" to "Compliance mapping" with
  ISO and BSI batches; "Mapping history" lists ISO and BSI runs.
- Data Hub BSI tile shows the BSI search index state and can build or
  rebuild it.
- Update AGENTS.md and the implementation plan with these decisions.
This commit is contained in:
Binh 2026-10-09 13:40:15 +02:00
parent e44af77474
commit d26b672e2c
55 changed files with 2410 additions and 239 deletions

File diff suppressed because one or more lines are too long

View File

@ -2629,9 +2629,53 @@ Status:
- [x] Stufe 1 Datenschicht: Konverter `src/compliance/bsi-grundschutz.ts`, Generator `server/compliance/update-bsi-assets.mjs`, gepinnte Snapshots in `server/compliance/data/` (Katalog 2026-09-24, Zuordnung 1.0.1-qa, BSI-Commit `a128311`), Ableitung mit Diagnosen, synthetische Tests (2026-10-08)
- [x] Stufe 1 Darstellung: Client-Info-Kachel, Finding Reader, Work-Facetten (Produktversion 2.22.0, 2026-10-08; Sichtpruefung beider Themes in der laufenden App steht aus)
- [ ] Stufe 2: BSI-Korpus, manuelle Zuordnung, Migration (separate Freigabe)
- [x] Stufe 2: BSI-Korpus, direkte Zuordnung, Migration 029 (Produktversion 2.23.0, 2026-10-09; Sichtpruefung beider Themes und ein erster echter BSI-Lauf in der laufenden App stehen aus)
- [x] Stufe 2 Konsistenzpruefung ISO <-> BSI mit Review im Tab `Compliance` (Produktversion 2.23.0, 2026-10-09)
- [x] `Update from BSI` mit Sicherheitsmodell-Aktualisierung (Data-Hub-Kachel, in Produktversion 2.22.0 enthalten, 2026-10-08)
##### Stufe 2 mit Konsistenzpruefung (Nutzerentscheidung 2026-10-09, umgesetzt in 2.23.0)
Ziel: Die ISO-Zuordnungen verbessern, indem das Modell Befunde zusaetzlich direkt BSI-Kernel-Anforderungen zuordnet und das Ergebnis je Regel mit der Stufe-1-Ableitung (akzeptierte ISO-Kontrollen -> offizielle BSI-Zuordnung) verglichen wird. Abweichungen erscheinen zur menschlichen Pruefung. Explizite Nutzerentscheidungen vom 2026-10-09:
- Umfang: vollstaendige Stufe 2 (eigener BSI-Korpus, direkte Zuordnung, Migration), keine reine Verifikation der abgeleiteten Anforderungen.
- Die vorlaeufige automatische Annahme der ersten zwei ISO-Vorschlaege bleibt unveraendert; eine Inkonsistenz haelt sie nicht zurueck.
- Die Pruefung findet im Client-Info-Tab `Compliance` statt (Engine-Code), nicht in einer neuen Work-Facette.
- Vorlaeufig (Nutzerentscheidung 2026-10-09, kann spaeter geaendert werden): auch direkte BSI-Zuordnungen nehmen wie ISO die ersten zwei Vorschlaege automatisch an, mit demselben Kommentar; eine bestehende Entscheidung bleibt erhalten.
Vergleichsregel (`src/compliance/bsi-consistency.ts`): Je Regel werden die direkt akzeptierten BSI-Anforderungen (juengste Entscheidung je Anforderung, also auch die automatisch angenommenen) mit den Zielen der akzeptierten ISO-Kontrollen in der BSI-Zuordnung verglichen. Ohne akzeptierte BSI-Anforderung gibt es nichts zu vergleichen; eine Regel mit akzeptierter ISO-Kontrolle zaehlt dann als `Not compared`. Drei Zustaende:
- `Consistent`: mindestens eine direkte Anforderung liegt in der Zielmenge einer akzeptierten ISO-Kontrolle. Ein Treffer genuegt, weil `subset-of` mehrere Anforderungen umfasst und ein Befund selten alle trifft.
- `Inconsistent`: mindestens eine direkte Anforderung ist ueber die BSI-Zuordnung erreichbar, aber keine liegt in der abgeleiteten Menge (auch ohne akzeptierte ISO-Kontrolle). Pruefpflichtig.
- `No reference`: keine direkte Anforderung ist ueber die BSI-Zuordnung von einer ISO-Kontrolle erreichbar (629 der 901 Kernel-Anforderungen). Keine Abweichung, sondern die Luecke, die die direkte Zuordnung schliesst.
Zusaetzlich als Hinweis die Gegenrichtung: Fuehrt eine direkte Anforderung rueckwaerts ueber die BSI-Zuordnung zu einer nicht akzeptierten ISO-Kontrolle, nennt die Pruefansicht diese Kontrolle als Kandidaten. Der Zustand wird deterministisch aus Entscheidungen, Laeufen und der aktiven Zuordnungsversion berechnet, nicht gespeichert. Uebereinstimmung belegt Konsistenz, nicht Richtigkeit: beide Zuordnungen stammen vom selben Modell und Befundtext.
Grenzen: Eine Inkonsistenz aendert, verwirft oder blockiert keine ISO-Entscheidung; abgeleitete Anforderungen werden weiterhin nie als Entscheidung gespeichert. Nur der Mensch entscheidet. `Map new scans automatically` umfasst BSI weiterhin nicht. BSI-Texte mit Namensnennung (CC BY-SA 4.0), ISO-Texte weiterhin nicht im Browser.
Umsetzung 2.23.0 (urspruenglicher Entwurf der Pruefansicht: Liste der Regeln mit Abweichungen des Timeslots, gruppiert nach Zustand (`Inconsistent` zuerst), je Regel akzeptierte ISO-Kontrollen mit ihrer abgeleiteten BSI-Menge, direkte BSI-Vorschlaege und die Kandidaten der Gegenrichtung. Aktionen: Accept/Reject der direkten BSI-Vorschlaege und der ISO-Kontrollen ueber den bestehenden Entscheidungsweg, `Mark reviewed` fuer eine bewusst belassene Inkonsistenz. `Mark reviewed` gilt fuer einen Fingerabdruck der verglichenen Mengen; aendern sich Entscheidungen oder Zuordnungsversion, erscheint die Regel erneut. Befundzahl und Sprung nach Work wie die bestehenden ISO-Drilldowns.)
- Datenhaltung: Migration 029 (`framework` in `assessor_regulatory_mappings` mit Eindeutigkeit einschliesslich Framework und in `assessor_regulatory_mapping_decisions`, Werte `iso27001`/`bsi-kernel`; neue Tabelle `assessor_regulatory_consistency_reviews`). Vor der Migration Backup `backups/assessor-pre-029-20261009.dump`, per Wiederherstellung in eine Pruefdatenbank verifiziert (Zaehler identisch); danach alle 162 Zuordnungen und 325 Entscheidungen `iso27001`. ISO-Objekte tragen im JSON kein Framework-Feld, ISO-Zuordnungs-IDs, Batch-Scope-Schluessel und URLs bleiben unveraendert.
- BSI-Korpus (`server/compliance/bsi-corpus.ts`): je Kernel-Anforderung ein deutsches Dokument (Aussage und Erlaeuterung mit eingesetzten Parametern) unter `corpusId` `bsi-grundschutz-kernel`, Version aus Katalogdatum und Quell-Hash. Er wird beim ersten BSI-Lauf aus dem aktiven Katalog gebaut (auch nach `Update from BSI`), aeltere Katalogstaende werden danach entfernt.
- BSI-Zuordnung: derselbe Dienst mit Framework-Parameter, Retrieval `bsi-dense-requirement-1.0.0` (40 Treffer, bis zu 12 Anforderungen, jeweils alle Chunks der Anforderung als Evidenz), Prompt `bsi-1.0.0`, Evidenz ohne Seitenangaben. Explizite Nutzerentscheidung 2026-10-09, spaeter am Tag (ersetzt die reine ISO-Historie der Entscheidung vom 2026-10-08): `Last mapping` und das Fenster, jetzt `Mapping history`, listen ISO- und direkte BSI-Laeufe (Batches und Einzelzuordnungen) gemeinsam; die Tabelle hat nach `Started` die Spalte `Framework` (`ISO 27001/27002`, `BSI Grundschutz++`), der Knopf nennt einen BSI-Lauf mit `· BSI` nach dem Datum, ein ISO-Lauf behaelt seine bisherige Beschriftung. `Map new scans automatically` umfasst BSI nicht.
- Startpunkte: Finding Reader, Abschnitt `BSI requirements` mit eigenem Teil `Mapped directly`, Knopf `Map BSI requirements` (Fenster `BSI requirement mapping` wie das ISO-Fenster) und dem Konsistenzzustand in der Zusammenfassung; der Abschnitt bleibt je Befund eingeklappt und erscheint fuer den direkten Teil erst, wenn das Backend den BSI-Zustand geliefert hat. Result-DB-Fenster `ISO mapping batch` mit zweitem Teil `BSI Grundschutz++ requirements` und `Start BSI mapping batch` fuer dieselben Filter.
- Pruefansicht: Kachel `ISO ↔ BSI review` in der Frameworkkachelzeile des Tabs `Compliance` (Compared rules, Inconsistent, No ISO reference, Consistent, Not compared, Reviewed; Gedankenstrich waehrend des Ladens). Ihr Results-Fenster listet die Regeln nach Zustand (Start `Inconsistent`), benannt nur ueber eine Result-DB-Zeile des Timeslots, nie ueber den internen Regelschluessel. Je Regel: akzeptierte ISO-Kontrollen (Reject), direkte BSI-Anforderungen mit Herkunft (Reject), ISO-Kandidaten der Gegenrichtung (Accept nur, wenn der aktuelle ISO-Lauf sie abgerufen hat), `Mark reviewed` mit optionalem Kommentar fuer den aktuellen Fingerabdruck.
- Core-Aenderungen mit Core-Freigabe Store, Presentation, Platform, Core Tests vom 2026-10-09: Migration und Tabellenklassifikation, HTTP-Routen (`framework`-Parameter, `regulatory-mapping-consistency`, `regulatory-mapping-consistency-reviews`), API-Client, Finding-Reader-Einbindung des BSI-Abschnitts, Batch-Ueberschriftenstil, Versionsvertragstests. AGENTS.md beschreibt Stufe 2 noch als ausstehend; die Aktualisierung braucht eine Governance-Freigabe.
- Verifikation: synthetische Tests fuer Vergleichsregel, BSI-Lauf und Entscheidungstrennung, Pruefliste mit `Mark reviewed`, Batch, Korpusaufbau, UI-Interaktionen (Kachel, Fenster, Batchdialog, Finding Reader), erweiterter Invarianztest; `npm test` (alle bestanden) und beide Builds. Ausstehend: Sichtpruefung beider Themes und ein erster echter BSI-Lauf in der laufenden App.
Urspruengliche Umsetzungsschritte (Entwurf):
1. Migration (Core `Store`, separate Freigabe, vorher verifiziertes Backup): `framework` (`iso27001`, `bsi-grundschutz-kernel`) in `assessor_regulatory_mappings` (Eindeutigkeit einschliesslich Framework) und `assessor_regulatory_mapping_decisions`; bestehende Zeilen erhalten `iso27001`. Neue Engine-Tabelle fuer `Mark reviewed` (Regel, Fingerabdruck, Kommentar, Zeit). Tabellenklassifikation im Repository.
2. BSI-Korpus (Engine): Anforderung und Erlaeuterung der 901 Kernel-Anforderungen aus dem aktiven Snapshot unter eigenem `corpusId` im Qdrant-/BGE-M3-Pfad, Neuaufbau bei `Update from BSI`.
3. BSI-Zuordnung (Engine): eigene Prompt- und Schemaversion fuer deutsche `MUSS`/`SOLLTE`-Anforderungen, Einzel- und Batchlauf ueber den bestehenden Dienst mit Framework-Parameter; Wiederverwendung erfolgreicher Laeufe je Regel wie bei ISO.
4. Startpunkte (Core `Presentation`, `Platform`): Framework-Wahl oder zweite Aktion bei `Map ISO controls` im Finding Reader und beim Result-DB-Batch; Framework-Parameter in den HTTP-Routen.
5. Konsistenzberechnung und Pruefansicht (Engine): reine Funktion mit synthetischen Tests fuer alle drei Zustaende, Gegenrichtung und Fingerabdruck; Pruefliste im Tab `Compliance`.
6. Finding Reader `BSI requirements` (Engine-Inhalt): direkte und abgeleitete Anforderungen getrennt kenntlich.
7. Invarianztest der Compliance Engine erweitern; `npm test`, beide Builds, Produktversion (Minor).
Vor der Umsetzung offen, am 2026-10-09 entschieden: Migration mit Backup und Core-Freigabe erteilt; direkte BSI-Vorschlaege werden vorlaeufig wie ISO automatisch angenommen (Nutzerentscheidung entgegen dem Vorschlag, nur manuell anzunehmen).
Risiko: Weil beide Seiten automatisch angenommen werden und vom selben Modell stammen, zeigt `Consistent` zunaechst nur die Uebereinstimmung zweier Automatiken; die Entscheidung ist ausdruecklich vorlaeufig.
#### Client-Info-Tab `Compliance`: Uebersichtszeile (Nutzerentscheidung 2026-10-08, umgesetzt)
Nach Freigabe eines lokalen, nicht versionierten Mockups mit synthetischen Daten steht ueber der unveraenderten Frameworkkachelzeile eine Uebersichtszeile in denselben sechs Spuren. Reiner Engine-Code (`src/compliance/ui/ComplianceOverview.tsx`, `compliance.css`, `server/compliance/regulatory-mapping-service.ts`); kein Erweiterungspunkt und keine Core-Datei geaendert.
@ -2783,6 +2827,7 @@ Festlegung 2026-09-06 (Phase 13B/13C, ausdruecklicher Nutzerwunsch): Vor Rueckfr
Festlegung vom 2026-09-06 (Phase 11, lokaler Persistenzbetrieb): Der vorhandene Docker-Compose-Bestand `assessor-2_0` mit der Datenbank `assessor`, dem Container `assessor-2_0-postgres-1` und dem persistenten Volume `assessor-2_0_assessor-postgres-data` ist der einzige kanonische lokale ASSESSOR-Datenbestand fuer diesen Workspace. Lokale Starts verwenden ausschliesslich `127.0.0.1:5432`. Die Compose-Konfiguration bindet dieses Volume deshalb namentlich als `external`; bei fehlendem Bestand oder Portkonflikt muss der Start fehlschlagen, statt eine weitere ASSESSOR-Datenbank oder einen Ersatzport anzulegen. Diese ausdrueckliche Nutzerfestlegung ersetzt die bisherige implizite, verzeichnisabhaengige Compose-Projektidentitaet. Der interne Browser-/Backend-/PostgreSQL-Zielaufbau bleibt unveraendert.
| Datum | Entscheidung | Begründung |
| 2026-10-09 | BSI Stufe 2 wird vollstaendig umgesetzt und um eine Konsistenzpruefung ISO <-> BSI erweitert: direkte BSI-Zuordnung je Regel gegen die Ableitung aus akzeptierten ISO-Kontrollen, Zustaende `Consistent`, `Inconsistent`, `No reference`, Pruefung im Tab `Compliance`; die automatische Annahme der ISO-Vorschlaege bleibt unveraendert (Phase 13F, Abschnitt Stufe 2 mit Konsistenzpruefung, Entwurf). | Nutzerentscheidung; Abweichungen zwischen beiden Wegen zeigen pruefwuerdige ISO-Zuordnungen, ohne eine Entscheidung automatisch zu aendern. |
| 2026-10-08 | BSI Grundschutz++ wird zweites Framework der Compliance Engine: direkte Zuordnung mit der offiziellen BSI-Zuordnung ISO 27001 -> Grundschutz++ als erster Stufe, nur Kernel-Anforderungen, Katalogbeschaffung wie bei MITRE, zunaechst nur manuelle Zuordnung (Phase 13F, Abschnitt BSI Grundschutz++). | Nutzerentscheidung; die BSI-Zuordnung liefert ohne Modellaufruf eine erste Einordnung, die direkte Zuordnung die anforderungsgenaue Praezision. |
| 2026-09-23 | Der globale ISO-Korpus wird bis zur Phase-14-Nutzerverwaltung nur mit einem separaten Operator-Upload-Token aus Data Hub erneuert. Die privaten versionierten JSON-Korpusquellen bleiben im Artefakt-Volume, Original-PDFs nur temporaer; der freigegebene Embedding-Endpunkt darf den lizenzierten Kontrolltext erhalten. | Der Nutzer hat Token, private Wiederaufbauquelle und Embedding-Ziel ausdruecklich bestaetigt. Eine fuer alle Projekte wirksame Korpusaktivierung darf nicht allein von der allgemeinen VPN-/App-Erreichbarkeit abhangen. |
| 2026-09-14 | AI Overview verwendet ausschliesslich das per Backend konfigurierte, unternehmensverwaltete LiteLLM ueber HTTPS; ein lokales Modell oder Browserzugriff auf Gateway/Key ist nicht vorgesehen. Der vollstaendige aktive Timeslot wird ueber den bestehenden Privacy-Safe-JSON-Vertrag uebertragen, grosse Eingaben werden ohne Record-Trunkierung hierarchisch zusammengefuehrt, und nur validierte Endergebnisse werden je Timeslot persistiert. | Der Nutzer hat Work-LiteLLM samt URL, Modellalias und API-Key bereit. Die bestehende Exportgrenze verhindert einen zweiten, weiter gefassten AI-Datenvertrag; serverseitige Secrets, explizite Ausloesung, lokale Validierung und append-only Historie halten Netz-, Kosten-, Datenschutz- und Auditgrenzen nachvollziehbar. |
@ -3724,6 +3769,13 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
| Datum | Änderung |
| 2026-10-09 | Governance (Core-Freigabe Governance): AGENTS.md uebernimmt die Entscheidungen des Tages, BSI Stufe 2 mit Migration 029, Konsistenzpruefung und Kachel `ISO ↔ BSI review`, Hybridsuche, volle ISO-27002-Kontrolltexte, `Compliance mapping` statt `Map all ISO controls`, `Mapping history` mit ISO und BSI, BSI-Suchindex in der Data-Hub-Kachel und die automatische Annahme nur starker Vorschlaege. |
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht; Core-Freigabe Presentation, Platform), Nutzerauftrag: Die Result-DB-Aktion `Map all ISO controls` heisst `Compliance mapping` (Fenster `Compliance mapping`, Aktivitaeten `Starting/Loading compliance mapping batch`), weil sie ISO und BSI startet. Die Data-Hub-Kachel `BSI GRUNDSCHUTZ++ KERNEL` zeigt den Suchindex des aktiven Katalogs (`Built`, `Building` mit Fortschritt, `Not built`, `Outdated`, `Build failed`, `Unavailable`) und bietet `Build search index` beziehungsweise `Rebuild search index`; neue Routen `GET /reference-data/bsi-grundschutz/search-index` und `POST .../search-index/build` (202, `force` fuer den Neuaufbau). Eine BSI-Zuordnung baut einen fehlenden Index weiterhin selbst. Mit Datenbankbereinigung: auf Nutzerwunsch wurden alle Zuordnungsdaten (242 Laeufe, 355 Entscheidungen, 5 Batches) nach verifizierter Sicherung `backups/assessor-pre-clear-mappings-20261009.dump` geloescht; neu zugeordnet wird durch den Nutzer. |
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht, nur Engine-Code), Nutzerentscheidung (ersetzt die vorlaeufige Annahme der ersten zwei Vorschlaege vom 2026-10-05 fuer ISO und BSI): automatisch angenommen werden hoechstens die ersten zwei starken Vorschlaege in Modellreihenfolge, also `primary` mit Konfidenz `high` oder `medium`; schwaechere Vorschlaege warten auf eine Person, ohne starken Vorschlag wird nichts angenommen. Bestehende Entscheidungen bleiben unveraendert. Auf den 20 Purple-Knight-Validierungslaeufen: ISO 21 von 25 automatischen Annahmen in der Referenz (vorher 29 von 39), BSI 16 von 26 (vorher 18 von 37). AGENTS.md beschreibt noch die alte Regel; die Anpassung braucht eine Governance-Freigabe. |
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht, nur Engine-Code), Nutzerauftrag: Hybridsuche fuer ISO und BSI. Je Lauf formuliert das Modell die oeffentliche Abfrage einmal auf Deutsch (`de-expansion-1.0.0`, je Abfrage geteilt zwischen ISO und BSI, im Lauf als `expansion` gespeichert); drei Ranglisten (dichte Suche Englisch, dichte Suche Deutsch, BM25 ueber die deutschen Begriffe im Speicher, `server/compliance/regulatory-lexical-index.ts`) werden per Reciprocal Rank Fusion auf Kontrollebene vereint. BSI laesst BES, DLS, GEB und PERS aus und sendet 16 statt 12 Kandidaten. Ohne deutsche Umformulierung bleibt es bei der englischen dichten Suche. Retrieval-Versionen `hybrid-rrf-de-1.0.0` und `bsi-hybrid-rrf-de-1.0.0`. Abfrage-Korrektur: Purple Knight wird unter `purple-knight-ad/-entra/-combined` erkannt, Indikator-IDs `SI` plus sechs Ziffern zaehlen als oeffentliche Regel-ID. Validierung an den 20 Purple-Knight-Regeln gegen die Referenztabelle: ISO-Referenz in den Kandidaten 42/50 (vorher 33), BSI 21/45 (vorher 4); automatisch angenommene Vorschlaege in der Referenz ISO 29/39 (vorher 28/39), BSI 18/37 (vorher 5/25). Die Laeufe nahmen 19 ISO- und 28 BSI-Entscheidungen automatisch an. |
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht, nur Engine-Code), Nutzerentscheidung: ISO-Prompt 1.3.0. Das Retrieval waehlt weiterhin die 8 Kontrollen; das Modell erhaelt je Kontrolle den 27001-Kontrolltext und den vollstaendigen 27002-Text (Kontrolle, Zweck, Leitlinien in Dokumentreihenfolge, danach weitere Informationen, soweit Platz), hoechstens 8.000 Zeichen je Kontrolle bei stets enthaltenen Treffern; Promptgrenze 96.000 Zeichen, bis zu 24 Evidenzabschnitte je Kandidat. Der Browser erhaelt weiterhin nur Zitate. Validierung an 20 Purple-Knight-Regeln (Client mit Nutzerfreigabe): erfolgreich, Prompt-Tokens Median 7.225 -> 17.994; die ersten zwei Vorschlaege bleiben bei 12 Regeln gleich, bei 8 aendern sie sich ohne messbare Verbesserung gegen die Referenz; Engpass bleibt die Kandidatenauswahl des Retrievals. Der Lauf nahm 9 Kontrollen automatisch neu an. |
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (noch unveroeffentlicht, daher kein eigener Versionssprung; nur Engine-Code), Nutzerentscheidung: `Last mapping` und das Fenster `Mapping history` zeigen auch direkte BSI-Laeufe mit Spalte `Framework`; ein BSI-Lauf heisst auf dem Knopf `· BSI`. Synthetische Dienst- und UI-Tests angepasst und ergaenzt. |
| 2026-10-09 | Phase 13F, Produktversion 2.23.0 (Minor: neue sichtbare Faehigkeit; Core-Freigabe Store, Presentation, Platform, Core Tests): BSI Grundschutz++ Stufe 2. Direkte BSI-Zuordnung mit eigenem Korpus und Prompt, Migration 029 nach verifiziertem Backup, Konsistenzpruefung ISO <-> BSI mit Kachel und Pruefliste im Tab `Compliance`, BSI-Teil im Finding Reader und im Batchdialog; vorlaeufig automatische Annahme auch fuer BSI. Details im Abschnitt Stufe 2 mit Konsistenzpruefung. |
| 2026-10-08 | Phase 13F, in Produktversion 2.22.0 enthalten (nur Engine-Code), Nutzerentscheidung: Der Finding-Reader-Abschnitt `BSI requirements` war in Work zu gross. Er startet jetzt wie `Context` eingeklappt (Zusammenfassung mit Anzahl), gruppiert die Anforderungen unter ihrer ISO-Kontrolle (`A.5.18 · subset-of`, die Beziehung gilt fuer die ganze BSI-Gruppe) als einzeilige Eintraege und zeigt den Anforderungstext erst beim Aufklappen eines Eintrags; die Quellenangabe ist eine kurze Zeile, der vollstaendige Lizenzhinweis bleibt im Client-Info-Results-Fenster. Ein ueber zwei ISO-Kontrollen erreichte Anforderung steht unter beiden. Ein gemeinsamer `Compliance`-Kasten fuer ISO und BSI wurde erwogen und vom Nutzer verworfen; `ISO controls` bleibt unveraendert offen. Test: eingeklappt, Gruppen, Aufklappen eines Eintrags, neues Finding wieder eingeklappt, Engine aus. |
| 2026-10-08 | Phase 13F, in Produktversion 2.22.0 enthalten (noch unveroeffentlicht, daher kein eigener Versionssprung; Core-Freigabe Presentation, Platform, Governance, Core Tests), Nutzerauftrag: Data-Hub-Kachel `BSI GRUNDSCHUTZ++ KERNEL` wie die MITRE-Matrix unter der ISO-Korpus-Kachel, nur bei eingeschalteter Engine. Plakette mit Katalogdatum, Zuordnungsversion und Herkunft (`Bundled`/`Updated`); `Update from BSI` laedt im Backend genau die zwei festen offiziellen Dateien vom BSI-Branch `main` (HTTPS, keine Weiterleitung, 120 s, 32 MiB je Datei), prueft sie fail-closed mit dem Konverter, aktiviert nie einen aelteren Stand und speichert den kompakten Stand mit Quell-URL und SHA-256 atomar und privat in `regulatory-corpora/bsi/active-catalog.json`; `Use bundled catalog` erscheint nur danach und kehrt zu den gepinnten Snapshots zurueck. Engine-Dienst `BsiCatalogService`, die Ableitung liest den aktiven Stand; Routen `GET /reference-data/bsi-grundschutz`, `POST .../refresh` und `POST .../reset` (Fehler 502 mit Code, ohne Dienst 503); Aktivitaetslabels; nach Update oder Ruecksetzen laden Client Info und Work neu. Sicherheitsmodell in `SECURITY.md` fuer das neue ausgehende Ziel ergaenzt (Nutzerfreigabe), `AGENTS.md` fortgeschrieben. Tests: Dienst (Standard, Update, unveraendert, kein Rueckschritt, Fehlerfaelle ohne Aenderung, Ruecksetzen, beschaedigter und veralteter Speicher), Routen, Kachelinteraktion, Engine aus, Platzierung, Ableitung aus dem aktiven Stand. Kein Live-Abruf gegen GitHub in Tests. |
| 2026-10-08 | Phase 13F, Produktversion 2.22.0 (Minor: neue sichtbare Faehigkeit; Core-Freigabe Presentation, Platform, Core Tests, Governance): BSI Grundschutz++ Stufe 1 sichtbar. Der Engine-Dienst haengt an Einzelzuordnung (`GET regulatory-mappings`), Client-Info-Uebersicht und Work-Zuordnungen ein optionales `bsi`-Feld mit den aus akzeptierten ISO-Kontrollen ueber die gepinnte BSI-Zuordnung abgeleiteten Kernel-Anforderungen (Anforderungstext mit aufgeloesten Parametern, ISO-Route und Beziehung, Katalog-/Zuordnungsversion, CC-BY-SA-Hinweis, fehlende Ziele); ohne Ableitung entfaellt das Feld, ISO-Antworten bleiben unveraendert; keine neue Route. Finding Reader: Abschnitt `BSI requirements` direkt nach `ISO controls`. Client Info `Compliance`: Kachel `BSI Grundschutz++` neben ISO mit `Requirements`, `Findings` und den vier Praktiken mit den meisten Findings (ein Finding zaehlt je Praktik einmal); `Results` listet alle Anforderungen nach Praktik in Katalogreihenfolge mit ISO-Route, Quelle und fehlenden Zielen; Zeile und Praktik oeffnen Work mit `Deviation` und der Facette `BSI requirement` bzw. `BSI practice`. Work: beide Facetten nach den ISO-Facetten und vor `Source: Scan`, nur wenn eine Anforderung abgeleitet ist; Werte in Kleinschreibung wie `ISO theme`. `AGENTS.md` nennt Entscheidung und Erweiterungspunkte. Tests: Dienst (Ableitung, Ablehnung entfernt sie), Kachel und Results-Fenster, Finding-Reader-Abschnitt mit Engine aus, Work-Facetten und Filterung, Client-Info-Drilldown; `npm test` 2165 bestanden, 18 uebersprungen; beide Produktionsbuilds bestanden. Sichtpruefung in der laufenden App (beide Themes) steht aus. |

4
package-lock.json generated
View File

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

View File

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

View File

@ -36,13 +36,16 @@ import type {
RegulatoryMappingBatchCreateRequest,
RegulatoryMappingBatchScope,
RegulatoryMappingCreateRequest,
RegulatoryMappingDecisionCreateRequest
RegulatoryMappingDecisionCreateRequest,
RegulatoryFramework
} from '../src/compliance/regulatory-mapping';
import type { RegulatoryMappingService } from './compliance/regulatory-mapping-service';
import type { ComplianceStore } from '../src/compliance/compliance-store';
import { RegulatoryMappingServiceError } from './compliance/regulatory-mapping-service';
import { RegulatoryCorpusUploadError, type RegulatoryCorpusUploadService } from './compliance/regulatory-corpus-upload-service';
import { BsiCatalogError, type BsiCatalogService } from './compliance/bsi-catalog-service';
import type { BsiCorpus } from './compliance/bsi-corpus';
import type { RegulatoryConsistencyReviewCreateRequest } from '../src/compliance/bsi-consistency';
import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service';
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
@ -182,6 +185,8 @@ export interface AssessorHttpServerOptions {
complianceExport?: Pick<ComplianceStore, 'exportProject'>;
/** Compliance Engine: active BSI Kernel catalog and ISO mapping with Update from BSI. */
bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>;
/** Compliance Engine: the BSI Kernel search index the Data Hub BSI tile builds. */
bsiSearchIndex?: Pick<BsiCorpus, 'status' | 'startBuild'>;
isoUploadToken?: string;
}
@ -327,6 +332,11 @@ function unifiedQueryFrom(url: URL): UnifiedResultQuery {
};
}
/** The Compliance Engine framework of a mapping request; the service validates the value and defaults to ISO. */
function regulatoryFrameworkFrom(url: URL): RegulatoryFramework | undefined {
return (url.searchParams.get('framework') ?? undefined) as RegulatoryFramework | undefined;
}
function regulatoryMappingBatchScopeFrom(url: URL): RegulatoryMappingBatchScope {
const query = unifiedQueryFrom(url);
const { audience: _audience, projectId: _projectId, assessmentId: _assessmentId,
@ -788,6 +798,15 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
await readJson<Record<string, never>>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.bsiCatalogService.refresh(), requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz/search-index' && method === 'GET') {
if (!options.bsiSearchIndex) throw new HttpError(503, 'bsi_search_index_unavailable', 'The BSI search index is not configured.');
status = 200; sendJson(response, status, await options.bsiSearchIndex.status(), requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz/search-index/build' && method === 'POST') {
if (!options.bsiSearchIndex) throw new HttpError(503, 'bsi_search_index_unavailable', 'The BSI search index is not configured.');
const body = await readJson<{ force?: boolean }>(request, maximumBodyBytes);
status = 202; sendJson(response, status, await options.bsiSearchIndex.startBuild(body?.force === true), requestId); return;
}
if (segments.join('/') === 'reference-data/bsi-grundschutz/reset' && method === 'POST') {
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
@ -1187,7 +1206,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mappings' && segments.length === 4 && method === 'GET') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
status = 200; sendJson(response, status, await options.regulatoryMappingService.get(segments[1], segments[3]), requestId); return;
status = 200; sendJson(response, status, await options.regulatoryMappingService.get(segments[1], segments[3], regulatoryFrameworkFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-runs' && segments.length === 3 && method === 'POST') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
@ -1209,7 +1228,16 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-batches' && segments.length === 3 && method === 'GET') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
status = 200; sendJson(response, status, await options.regulatoryMappingService.getBatch(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;
status = 200; sendJson(response, status, await options.regulatoryMappingService.getBatch(segments[1], regulatoryMappingBatchScopeFrom(url), regulatoryFrameworkFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-consistency' && segments.length === 3 && method === 'GET') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
status = 200; sendJson(response, status, await options.regulatoryMappingService.consistency(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-consistency-reviews' && segments.length === 3 && method === 'POST') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
const body = await readJson<RegulatoryConsistencyReviewCreateRequest>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.regulatoryMappingService.markConsistencyReviewed(segments[1], body), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-batches' && segments.length === 3 && method === 'POST') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');

View File

@ -0,0 +1,127 @@
import type { RegulatoryControlDocument } from '../../src/compliance/regulatory-rag';
import { BSI_CORPUS_ID, BSI_SOURCE_STANDARD } from '../../src/compliance/regulatory-mapping';
import { bsiRequirementText, type BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz';
import type { BsiSearchIndexStatus } from '../../src/compliance/bsi-catalog-management';
import type { BsiCatalogs } from './bsi-snapshots';
import type { RegulatoryIngestionService } from './regulatory-rag-service';
export interface BsiCorpusOptions {
catalogs: () => Promise<BsiCatalogs>;
ingestion?: RegulatoryIngestionService;
countIndexed?: (corpusId: string, corpusVersion: string) => Promise<number>;
pruneIndexed?: (corpusId: string, keepCorpusVersion: string) => Promise<void>;
/** Chunks of every catalog state, so the Data Hub can tell an outdated index from a missing one. */
countCorpus?: (corpusId: string) => Promise<number>;
}
/** The corpus version of a Kernel catalog: its date and the start of its source hash, so every catalog state indexes once. */
export function bsiCorpusVersion(catalog: Pick<BsiKernelCatalog, 'version' | 'sourceSha256'>): string {
return `kernel-${catalog.version.slice(0, 10)}-${catalog.sourceSha256.slice(0, 12)}`;
}
/**
* One retrieval document per Kernel requirement: its statement and guidance with the parameters filled in, in the
* published German. The BSI text may be indexed and shown under CC BY-SA 4.0, unlike the ISO text.
*/
export function bsiCorpusDocuments(catalog: BsiKernelCatalog): RegulatoryControlDocument[] {
const corpusVersion = bsiCorpusVersion(catalog);
return catalog.requirements.map(requirement => ({
schemaVersion: 1,
corpusId: BSI_CORPUS_ID,
corpusVersion,
documentId: `bsi-kernel:${requirement.id}`,
framework: BSI_SOURCE_STANDARD,
frameworkVersion: catalog.version,
controlId: requirement.id,
language: 'de',
title: requirement.title,
text: [bsiRequirementText(requirement.statement, requirement.params),
requirement.guidance ? bsiRequirementText(requirement.guidance, requirement.params) : ''].filter(Boolean).join('\n\n'),
sourceUri: catalog.sourceUrl
}));
}
/**
* The BSI Kernel corpus in the shared Qdrant/BGE-M3 path under its own corpus id (Phase 13F, BSI stage 2). It is
* built from the active catalog from the Data Hub BSI tile or, when nobody did, when a BSI mapping first needs it,
* once per catalog state; older catalog states are pruned afterwards. Concurrent callers share one build.
*/
export class BsiCorpus {
private building?: { corpusVersion: string; task: Promise<string>; done: boolean; progress?: { completed: number; total: number } };
private failure?: { corpusVersion: string; error: string };
private builtAt?: { corpusVersion: string; at: string };
constructor(private readonly options: BsiCorpusOptions) {}
get configured(): boolean {
return Boolean(this.options.ingestion && this.options.countIndexed);
}
async corpusVersion(): Promise<string> {
return bsiCorpusVersion((await this.options.catalogs()).catalog);
}
/** The indexed corpus version of the active catalog, building it first when it is missing. */
async ensure(): Promise<string> {
if (!this.configured) throw new Error('Embedding or Qdrant is not configured.');
const { catalog } = await this.options.catalogs();
const corpusVersion = bsiCorpusVersion(catalog);
if (this.building?.corpusVersion !== corpusVersion) this.track(catalog, corpusVersion, false);
return this.building!.task;
}
/**
* Starts building the index of the active catalog from the Data Hub (user decision 2026-10-09) without waiting for
* it; `force` embeds every requirement again even when the index is complete. A running build is not started twice.
*/
async startBuild(force: boolean): Promise<BsiSearchIndexStatus> {
if (!this.configured) return this.status();
const { catalog } = await this.options.catalogs();
const corpusVersion = bsiCorpusVersion(catalog);
if (!(this.building?.corpusVersion === corpusVersion && !this.building.done)) this.track(catalog, corpusVersion, force).catch(() => undefined);
return this.status();
}
async status(): Promise<BsiSearchIndexStatus> {
const { catalog } = await this.options.catalogs();
const corpusVersion = bsiCorpusVersion(catalog);
const base = { version: 1 as const, catalogVersion: catalog.version, corpusVersion, requirementCount: catalog.requirements.length };
if (!this.configured) return { ...base, state: 'unavailable', indexedChunks: 0, disabledReason: 'Embedding or Qdrant is not configured.' };
const indexedChunks = await this.options.countIndexed!(BSI_CORPUS_ID, corpusVersion).catch(() => 0);
const building = this.building?.corpusVersion === corpusVersion ? this.building : undefined;
if (building && !building.done) return { ...base, state: 'building', indexedChunks, ...(building.progress ? { progress: { ...building.progress } } : {}) };
if (this.failure?.corpusVersion === corpusVersion) return { ...base, state: 'failed', indexedChunks, error: this.failure.error };
if (indexedChunks >= catalog.requirements.length) {
return { ...base, state: 'built', indexedChunks, ...(this.builtAt?.corpusVersion === corpusVersion ? { builtAt: this.builtAt.at } : {}) };
}
const anyVersion = await (this.options.countCorpus?.(BSI_CORPUS_ID) ?? Promise.resolve(0)).catch(() => 0);
return { ...base, state: anyVersion > indexedChunks ? 'outdated' : 'not-built', indexedChunks };
}
private track(catalog: BsiKernelCatalog, corpusVersion: string, force: boolean): Promise<string> {
const entry: NonNullable<BsiCorpus['building']> = { corpusVersion, task: Promise.resolve(corpusVersion), done: false };
entry.task = this.build(catalog, corpusVersion, force, (completed, total) => { entry.progress = { completed, total }; });
this.building = entry;
this.failure = undefined;
entry.task.then(() => {
entry.done = true;
this.builtAt = { corpusVersion, at: new Date().toISOString() };
}, (error: unknown) => {
// A failed build is retried by the next caller and reported in the Data Hub until then.
entry.done = true;
if (this.building === entry) this.building = undefined;
this.failure = { corpusVersion, error: error instanceof Error ? error.message.slice(0, 300) : 'The BSI search index could not be built.' };
});
return entry.task;
}
private async build(catalog: BsiKernelCatalog, corpusVersion: string, force: boolean,
onProgress: (completed: number, total: number) => void): Promise<string> {
const indexed = await this.options.countIndexed!(BSI_CORPUS_ID, corpusVersion).catch(() => 0);
if (force || indexed < catalog.requirements.length) {
await this.options.ingestion!.ingest(bsiCorpusDocuments(catalog), onProgress);
}
await this.options.pruneIndexed?.(BSI_CORPUS_ID, corpusVersion).catch(() => undefined);
return corpusVersion;
}
}

View File

@ -12,12 +12,15 @@ import { RegulatoryCorpusUploadService } from './regulatory-corpus-upload-servic
import { RegulatoryAutoMapping } from './regulatory-auto-mapping';
import { AiOverviewService, type AiOverviewServiceOptions } from './ai-overview-service';
import { BsiCatalogService } from './bsi-catalog-service';
import { BsiCorpus } from './bsi-corpus';
export interface ComplianceEngineRuntime {
mappingService: RegulatoryMappingService;
corpusUploadService: RegulatoryCorpusUploadService;
aiOverviewService: AiOverviewService;
bsiCatalogService: BsiCatalogService;
/** The BSI Kernel search index the Data Hub BSI tile builds and reports. */
bsiCorpus: BsiCorpus;
store: ComplianceStore;
/** Stops running work; ASSESSOR keeps its own repository open. */
stop(): Promise<void>;
@ -47,10 +50,18 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
const bsiCatalogService = new BsiCatalogService(join(corpusRoot, 'bsi', 'active-catalog.json'));
const bsiCatalogs = () => bsiCatalogService.catalogs();
let mappingService: RegulatoryMappingService;
// Without embedding or Qdrant the index reports itself unavailable.
let bsiCorpus = new BsiCorpus({ catalogs: bsiCatalogs });
let rag: ReturnType<typeof createRegulatoryRagRuntime> | undefined;
let disabledReason: string | undefined;
try {
rag = createRegulatoryRagRuntime();
const indexed = rag;
// Direct BSI mappings (Phase 13F, stage 2) retrieve from a BSI Kernel corpus built from the active catalog on first use.
bsiCorpus = new BsiCorpus({ catalogs: bsiCatalogs, ingestion: indexed.ingestion,
countIndexed: (corpusId, corpusVersion) => indexed.index.count({ corpusId, corpusVersion }),
pruneIndexed: (corpusId, corpusVersion) => indexed.index.deleteOtherCorpusVersions(corpusId, corpusVersion),
countCorpus: corpusId => indexed.index.count({ corpusId }) });
mappingService = new RegulatoryMappingService(read, store, {
findingRetrieval: rag.findingRetrieval,
index: rag.index,
@ -59,6 +70,9 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
corpusId: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID?.trim(),
corpusVersion: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION?.trim(),
bsiCatalogs,
bsiCorpus,
// Hybrid search: the same model restates each query in German for the German norm texts.
queryExpansionClient: options.aiClient,
...(!options.aiClient ? { disabledReason: 'Work LiteLLM is not configured.' } : {})
});
} catch (error) {
@ -86,6 +100,7 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
corpusUploadService,
aiOverviewService,
bsiCatalogService,
bsiCorpus,
store,
stop: () => Promise.all([autoMapping.stop(), corpusUploadService.stop().then(() => mappingService.close()), aiOverviewService.close()]).then(() => undefined),
close: async () => { await pool?.end(); }

View File

@ -6,11 +6,14 @@ import {
regulatoryMappingBatchRunValidationErrors,
regulatoryMappingDecisionValidationErrors,
regulatoryMappingRunValidationErrors,
mappingFramework,
type RegulatoryFramework,
type RegulatoryMappingBatchRun,
type RegulatoryMappingDecision,
type RegulatoryMappingRun
} from '../../src/compliance/regulatory-mapping';
import { aiOverviewRunValidationErrors, assertAiOverviewTransition, type AiOverviewRun } from '../../src/compliance/ai-overview';
import { regulatoryConsistencyReviewValidationErrors, type RegulatoryConsistencyReview } from '../../src/compliance/bsi-consistency';
interface DataRow<T> { data: T }
@ -31,7 +34,7 @@ function dataRow<T>(result: QueryResult<DataRow<T>>): T | null {
}
/**
* The Compliance Engine's own tables (migration 026, and the AI overview runs of migration 025). Writes run in the engine's own transactions:
* The Compliance Engine's own tables (migrations 026 and 029, and the AI overview runs of migration 025). Writes run in the engine's own transactions:
* they never advance the ASSESSOR repository revision and never trigger a Result DB rebuild.
*/
export class PostgresComplianceStore implements ComplianceStore {
@ -65,13 +68,13 @@ export class PostgresComplianceStore implements ComplianceStore {
const inserted = await client.query(`
INSERT INTO assessor_regulatory_mappings
(id, rule_key, source_tool, source_rule_id, run_status, query_hash, corpus_id, corpus_version,
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb)
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data, framework)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb, $17)
ON CONFLICT DO NOTHING
RETURNING id
`, [run.id, run.ruleKey, run.sourceTool, run.sourceRuleId ?? null, run.status, run.queryHash, run.corpusId,
run.corpusVersion, run.embeddingModel, run.retrievalVersion, run.promptVersion, run.outputSchemaVersion,
run.model, run.createdAt, run.updatedAt, json(run)]);
run.model, run.createdAt, run.updatedAt, json(run), mappingFramework(run)]);
if (inserted.rowCount) return { run: clone(run), claimed: true };
const existing = dataRow(await client.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1 FOR UPDATE', [run.id]
@ -96,13 +99,13 @@ export class PostgresComplianceStore implements ComplianceStore {
await client.query(`
INSERT INTO assessor_regulatory_mappings
(id, rule_key, source_tool, source_rule_id, run_status, query_hash, corpus_id, corpus_version,
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb)
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data, framework)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb, $17)
ON CONFLICT (id) DO UPDATE SET
run_status = excluded.run_status, updated_at = excluded.updated_at, data = excluded.data
`, [run.id, run.ruleKey, run.sourceTool, run.sourceRuleId ?? null, run.status, run.queryHash, run.corpusId,
run.corpusVersion, run.embeddingModel, run.retrievalVersion, run.promptVersion, run.outputSchemaVersion,
run.model, run.createdAt, run.updatedAt, json(run)]);
run.model, run.createdAt, run.updatedAt, json(run), mappingFramework(run)]);
return clone(run);
});
}
@ -179,14 +182,14 @@ export class PostgresComplianceStore implements ComplianceStore {
const run = dataRow(await client.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1 FOR UPDATE', [decision.mappingId]
));
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded' || mappingFramework(run) !== mappingFramework(decision)) {
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
}
const latest = dataRow(await client.query<DataRow<RegulatoryMappingDecision>>(`
SELECT data FROM assessor_regulatory_mapping_decisions
WHERE rule_key = $1 AND control_id = $2
WHERE rule_key = $1 AND control_id = $2 AND framework = $3
ORDER BY created_at DESC, id DESC LIMIT 1
`, [decision.ruleKey, decision.controlId]));
`, [decision.ruleKey, decision.controlId, mappingFramework(decision)]));
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
// previous decisions leads back to the run that grounded it.
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) {
@ -198,20 +201,38 @@ export class PostgresComplianceStore implements ComplianceStore {
const stored = { ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) };
await client.query(`
INSERT INTO assessor_regulatory_mapping_decisions
(id, rule_key, source_tool, source_rule_id, mapping_id, control_id, decision, previous_decision_id, created_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
(id, rule_key, source_tool, source_rule_id, mapping_id, control_id, decision, previous_decision_id, created_at, data, framework)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
`, [stored.id, stored.ruleKey, stored.sourceTool, stored.sourceRuleId ?? null, stored.mappingId, stored.controlId,
stored.decision, stored.previousDecisionId ?? null, stored.createdAt, json(stored)]);
stored.decision, stored.previousDecisionId ?? null, stored.createdAt, json(stored), mappingFramework(stored)]);
return clone(stored);
});
}
async listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]> {
async listRegulatoryMappingDecisions(ruleKey: string, framework: RegulatoryFramework = 'iso27001'): Promise<RegulatoryMappingDecision[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryMappingDecision>>(`
SELECT data FROM assessor_regulatory_mapping_decisions
WHERE rule_key = $1
WHERE rule_key = $1 AND framework = $2
ORDER BY created_at DESC, id DESC
`, [ruleKey]));
`, [ruleKey, framework]));
}
async createRegulatoryConsistencyReview(review: RegulatoryConsistencyReview): Promise<RegulatoryConsistencyReview> {
const errors = regulatoryConsistencyReviewValidationErrors(review);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
await client.query(`
INSERT INTO assessor_regulatory_consistency_reviews (id, rule_key, fingerprint, created_at, data)
VALUES ($1, $2, $3, $4, $5::jsonb)
`, [review.id, review.ruleKey, review.fingerprint, review.createdAt, json(review)]);
return clone(review);
});
}
async listRegulatoryConsistencyReviews(): Promise<RegulatoryConsistencyReview[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryConsistencyReview>>(
'SELECT data FROM assessor_regulatory_consistency_reviews ORDER BY created_at DESC, id DESC'
));
}
async createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
@ -275,6 +296,9 @@ export class PostgresComplianceStore implements ComplianceStore {
regulatoryMappingDecisions: dataRows(await this.pool.query<DataRow<RegulatoryMappingDecision>>(
'SELECT data FROM assessor_regulatory_mapping_decisions ORDER BY created_at, id'
)),
regulatoryConsistencyReviews: dataRows(await this.pool.query<DataRow<RegulatoryConsistencyReview>>(
'SELECT data FROM assessor_regulatory_consistency_reviews ORDER BY created_at, id'
)),
aiOverviewRuns: (await this.listAiOverviewRuns(projectId)).filter(item => item.status === 'succeeded')
};
}

View File

@ -25,6 +25,8 @@ export interface RegulatoryChunkIndex {
replaceDocuments(chunks: IndexedRegulatoryChunk[]): Promise<void>;
searchDense(vector: number[], limit: number, filters?: RegulatoryRetrievalFilters): Promise<RegulatoryRetrievalCandidate[]>;
list(filters: RegulatoryRetrievalFilters, limit: number): Promise<RegulatoryChunk[]>;
/** Every chunk matching the filters, page by page; the keyword index of the hybrid search is built from it. */
listAll?(filters: RegulatoryRetrievalFilters): Promise<RegulatoryChunk[]>;
}
export class QdrantClientError extends Error {
@ -147,6 +149,23 @@ export class QdrantRegulatoryIndex implements RegulatoryChunkIndex {
return points.map(point => parseChunkPayload(object(point)?.payload));
}
async listAll(filters: RegulatoryRetrievalFilters): Promise<RegulatoryChunk[]> {
const chunks: RegulatoryChunk[] = [];
let offset: unknown;
do {
const response = await this.request('POST', `/collections/${REGULATORY_QDRANT_COLLECTION}/points/scroll`, {
filter: { must: [keywordCondition('embedding_model', this.options.embeddingModel), ...filterConditions(filters)] },
limit: 256, with_payload: true, with_vector: false, ...(offset === undefined ? {} : { offset })
});
const result = object(response.body)?.result;
if (!Array.isArray(result?.points)) throw new QdrantClientError('qdrant_invalid_response', 'Qdrant returned an invalid scroll response.');
chunks.push(...result.points.map((point: unknown) => parseChunkPayload(object(point)?.payload)));
offset = result.next_page_offset ?? undefined;
if (chunks.length > 100_000) throw new QdrantClientError('qdrant_response_too_large', 'The regulatory corpus exceeds the keyword index limit.');
} while (offset !== undefined);
return chunks;
}
async count(filters: RegulatoryRetrievalFilters): Promise<number> {
const response = await this.request('POST', `/collections/${REGULATORY_QDRANT_COLLECTION}/points/count`, {
filter: { must: [keywordCondition('embedding_model', this.options.embeddingModel), ...filterConditions(filters)] },

View File

@ -29,4 +29,9 @@ export class RegulatoryFindingRetrievalService {
const candidates = await this.retriever.retrieve({ query: preparedQuery.query, limit, filters });
return { sourceResultId: finding.sourceResultId, status: 'candidates', preparedQuery, candidates };
}
/** Dense search for a further query text of the same finding, such as its German restatement. */
retrieveText(query: string, limit: number, filters?: RegulatoryRetrievalFilters): Promise<RegulatoryRetrievalCandidate[]> {
return this.retriever.retrieve({ query, limit, filters });
}
}

View File

@ -0,0 +1,87 @@
import type { RegulatoryChunk, RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag';
/**
* In-memory BM25 over the chunks of one indexed corpus, the keyword half of the hybrid search (Phase 13F, user
* decision 2026-10-09). The corpora are small (some hundred to some thousand chunks), so the index is built once per
* corpus version from Qdrant and kept in memory; nothing is stored. Tokens are lower-cased words with a light German
* and English suffix stemming, so `Berechtigungen` and `Berechtigung` meet.
*/
const STOP_WORDS = new Set(('der die das und oder ein eine einer eines zu zur zum von mit für auf in im ist sind werden wird sollte '
+ 'muss kann nicht den dem des als bei auch über durch wie the and or of to a an in on for is are be with this that by as it its '
+ 'from not should must can').split(' '));
export function lexicalTokens(text: string): string[] {
return (text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])
.filter(word => word.length > 2 && !STOP_WORDS.has(word))
.map(word => word.replace(/(ungen|ung|en|er|es|e|n|s)$/u, ''));
}
export class RegulatoryLexicalIndex {
private readonly documents: Array<Map<string, number>>;
private readonly lengths: number[];
private readonly documentFrequency = new Map<string, number>();
private readonly averageLength: number;
constructor(private readonly chunks: readonly RegulatoryChunk[]) {
this.documents = chunks.map(chunk => {
const counts = new Map<string, number>();
for (const token of lexicalTokens(`${chunk.title} ${chunk.text}`)) counts.set(token, (counts.get(token) ?? 0) + 1);
return counts;
});
this.lengths = this.documents.map(counts => [...counts.values()].reduce((total, count) => total + count, 0));
for (const counts of this.documents) for (const token of counts.keys()) this.documentFrequency.set(token, (this.documentFrequency.get(token) ?? 0) + 1);
this.averageLength = this.lengths.reduce((total, length) => total + length, 0) / Math.max(1, this.lengths.length);
}
get size(): number { return this.chunks.length; }
/** Chunks with at least one query term, best first; equal scores keep corpus order, so results are deterministic. */
search(query: string, limit: number): RegulatoryRetrievalCandidate[] {
const terms = [...new Set(lexicalTokens(query))];
const total = this.documents.length;
const hits: RegulatoryRetrievalCandidate[] = [];
this.documents.forEach((counts, index) => {
let score = 0;
for (const term of terms) {
const frequency = counts.get(term);
if (!frequency) continue;
const documents = this.documentFrequency.get(term)!;
const idf = Math.log(1 + (total - documents + 0.5) / (documents + 0.5));
score += idf * (frequency * 2.2) / (frequency + 1.2 * (0.25 + 0.75 * this.lengths[index] / this.averageLength));
}
if (score > 0) hits.push({ score, chunk: this.chunks[index] });
});
return hits.sort((left, right) => right.score - left.score).slice(0, limit);
}
}
export interface FusedControl {
controlId: string;
/** Reciprocal rank fusion score over the ranked lists; it orders candidates and is no relevance probability. */
score: number;
/** Every distinct chunk of the control any list returned, in list order. */
chunks: RegulatoryChunk[];
}
/**
* Reciprocal rank fusion (k = 60) of ranked chunk lists at control level: a control ranks in each list by its best
* chunk. Ties fall back to the control id, so identical input yields identical candidates.
*/
export function fuseControlRankings(lists: ReadonlyArray<readonly RegulatoryRetrievalCandidate[]>, exclude?: (controlId: string) => boolean): FusedControl[] {
const fused = new Map<string, { score: number; chunks: Map<string, RegulatoryChunk> }>();
for (const list of lists) {
const order: string[] = [];
for (const hit of list) {
const controlId = hit.chunk.controlId;
if (exclude?.(controlId)) continue;
const entry = fused.get(controlId) ?? { score: 0, chunks: new Map<string, RegulatoryChunk>() };
if (!entry.chunks.has(hit.chunk.chunkId)) entry.chunks.set(hit.chunk.chunkId, hit.chunk);
fused.set(controlId, entry);
if (!order.includes(controlId)) order.push(controlId);
}
order.forEach((controlId, rank) => { fused.get(controlId)!.score += 1 / (60 + rank + 1); });
}
return [...fused.entries()]
.map(([controlId, entry]) => ({ controlId, score: entry.score, chunks: [...entry.chunks.values()] }))
.sort((left, right) => right.score - left.score || left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
}

View File

@ -25,6 +25,7 @@ import {
type RegulatoryMappingResponse,
type RegulatoryMappingResult,
type RegulatoryMappingRun,
type RegulatoryMappingProposal,
type RegulatoryControlFinding,
type RegulatoryControlOverviewItem,
type RegulatoryControlOverviewResponse,
@ -32,26 +33,58 @@ import {
regulatoryControlTheme,
AUTO_MAPPING_BATCH_KEY_PREFIXES,
MAPPING_HISTORY_LIMIT,
BSI_CORPUS_ID,
BSI_MAPPING_PROMPT_VERSION,
BSI_RETRIEVAL_VERSION,
BSI_SOURCE_STANDARD,
frameworkField,
mappingFramework,
type RegulatoryFramework,
type RegulatoryMappingHistoryEntry
} from '../../src/compliance/regulatory-mapping';
import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag';
import type {
RegulatoryChunk,
RegulatoryFindingQuery,
RegulatoryQueryExpansion,
RegulatoryRetrievalCandidate,
RegulatoryRetrievalFilters
} from '../../src/compliance/regulatory-rag';
import { fuseControlRankings, RegulatoryLexicalIndex, type FusedControl } from './regulatory-lexical-index';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval';
import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots';
import { bsiFootprint, bsiOverview, type BsiOverview } from '../../src/compliance/bsi-grundschutz';
import {
bsiConsistency,
bsiConsistencyFingerprintSource,
type RegulatoryConsistencyItem,
type RegulatoryConsistencyResponse,
type RegulatoryConsistencyReviewCreateRequest
} from '../../src/compliance/bsi-consistency';
import { bsiCorpusVersion, type BsiCorpus } from './bsi-corpus';
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';
import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
import { canonicalJson } from '../../src/shared/canonical-json';
const POLL_AFTER_MS = 1_500;
const UNSUPPORTED_KIND_MESSAGE = 'Only canonical scanner findings and GPO baseline comparisons can be mapped in this version.';
const MAXIMUM_PROMPT_CHARACTERS = 48_000;
// Prompt 1.3.0 sends each candidate's full control text: about 3,700 characters per control in the median, at most 8,000
// each, so eight long controls with their JSON escaping still fit.
const MAXIMUM_PROMPT_CHARACTERS = 96_000;
const MAXIMUM_CONTROL_TEXT_CHARACTERS = 8_000;
const SECTION_ORDER: readonly string[] = ['control', 'purpose', 'guidance', 'other-information', 'attributes'];
const CORE_SECTIONS = ['control', 'purpose', 'guidance'] as const;
const BATCH_PAGE_SIZE = 500;
const DEFAULT_MAXIMUM_CONCURRENT_RUNS = 2;
// User decision 2026-10-05 (provisional): the first proposals of a finished mapping are accepted automatically.
// User decision 2026-10-05 (provisional), narrowed 2026-10-09: of a finished mapping, at most the first two strong
// proposals (primary with high or medium confidence) are accepted automatically; weaker ones wait for a person.
export const AUTO_ACCEPTED_PROPOSALS = 2;
export const AUTO_ACCEPT_COMMENT = 'Accepted automatically: one of the first two proposals of the mapping.';
export const AUTO_ACCEPT_COMMENT = 'Accepted automatically: one of the first two strong proposals (primary, high or medium confidence) of the mapping.';
export function isStrongProposal(proposal: Pick<RegulatoryMappingProposal, 'relationship' | 'confidence'>): boolean {
return proposal.relationship === 'primary' && (proposal.confidence === 'high' || proposal.confidence === 'medium');
}
export interface RegulatoryMappingServiceOptions {
findingRetrieval?: RegulatoryFindingRetrievalService;
@ -65,6 +98,49 @@ export interface RegulatoryMappingServiceOptions {
maximumConcurrentRuns?: number;
/** The active BSI catalog and mapping (`Update from BSI`); the pinned snapshots by default. */
bsiCatalogs?: () => Promise<BsiCatalogs>;
/** The BSI Kernel retrieval corpus for direct BSI mappings (stage 2); without it BSI mapping is unavailable. */
bsiCorpus?: BsiCorpus;
/** The model that restates a query in German for the hybrid search; without it only the English dense search runs. */
queryExpansionClient?: AiCompletionClient;
}
/** The versions that identify one mapping of a rule in one framework. */
interface MappingPipeline {
framework: RegulatoryFramework;
corpusId: string;
corpusVersion: string;
retrievalVersion: string;
promptVersion: string;
}
const FRAMEWORKS: readonly RegulatoryFramework[] = ['iso27001', 'bsi-kernel'];
const ISO_CANDIDATE_LIMIT = 8;
// Hybrid search (user decision 2026-10-09): 16 BSI candidates found the reference requirement for 12 of 20 test rules, 12 for 10.
const BSI_CANDIDATE_LIMIT = 16;
const BSI_EXCLUDED_PRACTICES = /^(BES|DLS|GEB|PERS)\./;
const MAXIMUM_CACHED_EXPANSIONS = 500;
const QUERY_EXPANSION_VERSION = 'de-expansion-1.0.0';
const QUERY_EXPANSION_PROMPT = 'Rewrite one public security finding as a German search query for the German editions of ISO/IEC 27001/27002 and the BSI Grundschutz++ Kernel. Return JSON only: {"query": "<one German sentence naming the general security subject, as a standard would phrase it>", "terms": ["<8 to 15 German terms a standard would use for this subject, plus technical terms that stay English>"]}. Do not add customer data.';
function retrievalFilters(pipeline: MappingPipeline): RegulatoryRetrievalFilters {
return pipeline.framework === 'bsi-kernel' ? { corpusId: pipeline.corpusId, corpusVersion: pipeline.corpusVersion }
: { corpusId: pipeline.corpusId, corpusVersion: pipeline.corpusVersion, sourceStandard: 'ISO/IEC 27002', retrievalRole: 'semantic' };
}
/** The model's German restatement, bounded plain text only; anything else means no hybrid search for this run. */
function queryExpansion(value: unknown): Omit<RegulatoryQueryExpansion, 'searches'> | undefined {
if (!value || typeof value !== 'object') return undefined;
const item = value as { query?: unknown; terms?: unknown };
const plain = (text: unknown, maximum: number) => typeof text === 'string' && text.trim() && text.length <= maximum && !/<\/?[a-z][^>]*>/i.test(text) ? text.trim() : undefined;
const query = plain(item.query, 600);
const terms = Array.isArray(item.terms) ? item.terms.map(term => plain(term, 80)).filter((term): term is string => Boolean(term)).slice(0, 20) : [];
return query ? { language: 'de', version: QUERY_EXPANSION_VERSION, query, terms } : undefined;
}
function requestedFramework(value: unknown): RegulatoryFramework {
if (value === undefined || value === null || value === '') return 'iso27001';
if (!FRAMEWORKS.includes(value as RegulatoryFramework)) throw new RepositoryValidationError('framework must be iso27001 or bsi-kernel.');
return value as RegulatoryFramework;
}
export class RegulatoryMappingService {
@ -78,6 +154,8 @@ export class RegulatoryMappingService {
private activeWorkerCount = 0;
private activationPending = false;
private titleCache?: { key: string; titles: Record<string, string> };
private readonly expansions = new Map<string, Promise<Omit<RegulatoryQueryExpansion, 'searches'> | undefined>>();
private readonly lexicalIndexes = new Map<string, Promise<RegulatoryLexicalIndex | undefined>>();
constructor(
private readonly read: ComplianceReadPort,
@ -132,20 +210,40 @@ export class RegulatoryMappingService {
}
}
async get(projectId: string, resultProjectionId: string): Promise<RegulatoryMappingResponse> {
/** True when mapping can run for the framework; ISO also needs its active licensed corpus. */
private frameworkEnabled(framework: RegulatoryFramework): boolean {
if (framework === 'iso27001') return this.generationEnabled;
return !this.activationPending && Boolean(this.options.findingRetrieval && this.options.index && this.options.client
&& this.options.embeddingModel && this.options.bsiCorpus?.configured);
}
private async pipeline(framework: RegulatoryFramework): Promise<MappingPipeline> {
if (framework === 'iso27001') return { framework, corpusId: this.options.corpusId ?? '', corpusVersion: this.options.corpusVersion ?? '',
retrievalVersion: REGULATORY_RETRIEVAL_VERSION, promptVersion: REGULATORY_MAPPING_PROMPT_VERSION };
const { catalog } = await this.bsiCatalogs();
return { framework, corpusId: BSI_CORPUS_ID, corpusVersion: bsiCorpusVersion(catalog), retrievalVersion: BSI_RETRIEVAL_VERSION,
promptVersion: BSI_MAPPING_PROMPT_VERSION };
}
async get(projectId: string, resultProjectionId: string, requested?: RegulatoryFramework): Promise<RegulatoryMappingResponse> {
const framework = requestedFramework(requested);
const finding = await this.finding(projectId, resultProjectionId);
const supported = Boolean(buildRegulatoryFindingQuery(finding));
const availability = supported ? await this.availability() : { enabled: false };
const availability = supported ? await this.availability(framework) : { enabled: false };
const enabled = availability.enabled && supported;
const ruleKey = supported ? this.ruleKey(finding) : undefined;
const run = ruleKey && this.options.client ? await this.store.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null;
const decisions = ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey) : [];
const earlierControls = ruleKey ? await this.earlierControls(ruleKey, run, decisions) : [];
const bsi = await this.bsiOverview(acceptedControlIds(decisions));
const pipeline = await this.pipeline(framework);
const run = ruleKey && this.options.client ? await this.store.getRegulatoryMappingRun(this.mappingId(ruleKey, pipeline)) : null;
const decisions = ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey, framework) : [];
const earlierControls = ruleKey ? await this.earlierControls(ruleKey, run, decisions, framework) : [];
const isoDecisions = framework === 'iso27001' ? decisions : ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey) : [];
const bsi = await this.bsiOverview(acceptedControlIds(isoDecisions));
const consistency = framework === 'bsi-kernel' && ruleKey ? await this.ruleConsistency(ruleKey, acceptedControlIds(isoDecisions), acceptedControlIds(decisions)) : undefined;
return {
version: 1,
projectId,
resultProjectionId,
framework,
generationEnabled: enabled,
state: run?.status ?? (enabled ? 'empty' : 'unsupported'),
...(!supported ? { disabledReason: !isRegulatoryMappableResult(finding)
@ -156,7 +254,8 @@ export class RegulatoryMappingService {
...(run ? { run: withoutEvidenceText(run) } : {}),
decisions,
...(earlierControls.length ? { earlierControls } : {}),
...(bsi ? { bsi } : {})
...(bsi ? { bsi } : {}),
...(consistency ? { consistency: { state: consistency.state, reviewed: consistency.reviewed } } : {})
};
}
@ -164,46 +263,56 @@ export class RegulatoryMappingService {
* The run that grounds a decision: the current one, or for an earlier decision the newest older succeeded
* run of the rule that retrieved the control, so it stays reviewable after a pipeline version change.
*/
private async groundingMappingId(ruleKey: string, controlId: string): Promise<string> {
const currentId = this.mappingId(ruleKey);
private async groundingMappingId(ruleKey: string, controlId: string, pipeline: MappingPipeline): Promise<string> {
const currentId = this.mappingId(ruleKey, pipeline);
const current = await this.store.getRegulatoryMappingRun(currentId);
if (!current || current.candidates.some(candidate => candidate.controlId === controlId)) return currentId;
const older = (await this.store.listRegulatoryMappingRuns()).find(run => run.ruleKey === ruleKey && run.status === 'succeeded'
&& run.candidates.some(candidate => candidate.controlId === controlId));
&& mappingFramework(run) === pipeline.framework && run.candidates.some(candidate => candidate.controlId === controlId));
return older?.id ?? currentId;
}
/** Decided controls the current run did not retrieve, titled from the newest older run of the rule that did. */
private async earlierControls(ruleKey: string, run: RegulatoryMappingRun | null, decisions: RegulatoryMappingDecision[]): Promise<RegulatoryEarlierControl[]> {
private async earlierControls(ruleKey: string, run: RegulatoryMappingRun | null, decisions: RegulatoryMappingDecision[],
framework: RegulatoryFramework): Promise<RegulatoryEarlierControl[]> {
const current = new Set(run?.candidates.map(candidate => candidate.controlId) ?? []);
const missing = [...new Set(decisions.map(decision => decision.controlId))].filter(controlId => !current.has(controlId));
if (!missing.length) return [];
const known = new Map<string, RegulatoryMappingCandidate>();
for (const older of await this.store.listRegulatoryMappingRuns()) {
if (older.ruleKey !== ruleKey) continue;
if (older.ruleKey !== ruleKey || mappingFramework(older) !== framework) continue;
for (const candidate of older.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate);
}
return missing.sort((left, right) => left.localeCompare(right, 'en', { numeric: true })).map(controlId => {
const candidate = known.get(controlId);
return { controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, ...(candidate?.title ? { title: candidate.title } : {}) };
return { controlId, annexAControlId: candidate?.annexAControlId ?? (framework === 'iso27001' ? `A.${controlId}` : controlId),
...(candidate?.title ? { title: candidate.title } : {}) };
});
}
private disabledError(framework: RegulatoryFramework): RegulatoryMappingServiceError {
return new RegulatoryMappingServiceError('regulatory_mapping_disabled', framework === 'bsi-kernel' && this.generationEnabled
? 'BSI mapping is not configured.' : this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503);
}
async start(projectId: string, request: RegulatoryMappingCreateRequest): Promise<RegulatoryMappingResponse> {
if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503);
const framework = requestedFramework(request.framework);
if (!this.frameworkEnabled(framework)) throw this.disabledError(framework);
const finding = await this.finding(projectId, request.resultProjectionId);
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
if (!buildRegulatoryFindingQuery(finding)) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
const claimed = await this.claim(finding, true, 'single', await this.origin(projectId, request.origin));
if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding)).then(() => undefined));
return this.get(projectId, request.resultProjectionId);
const pipeline = await this.pipeline(framework);
const claimed = await this.claim(finding, true, 'single', pipeline, await this.origin(projectId, request.origin));
if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding, pipeline)).then(() => undefined));
return this.get(projectId, request.resultProjectionId, framework);
}
async getBatch(projectId: string, requestedScope: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchResponse> {
async getBatch(projectId: string, requestedScope: RegulatoryMappingBatchScope, requested?: RegulatoryFramework): Promise<RegulatoryMappingBatchResponse> {
const framework = requestedFramework(requested);
const scope = await this.validateBatchScope(projectId, requestedScope);
const scopeKey = this.batchScopeKey(projectId, scope);
const scopeKey = this.batchScopeKey(projectId, scope, framework);
const run = (await this.store.listRegulatoryMappingBatchRuns(projectId, scopeKey))[0];
const availability = await this.availability();
const availability = await this.availability(framework);
return {
version: 1,
projectId,
@ -217,7 +326,8 @@ export class RegulatoryMappingService {
}
async startBatch(projectId: string, request: RegulatoryMappingBatchCreateRequest): Promise<RegulatoryMappingBatchResponse> {
if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503);
const framework = requestedFramework(request.framework);
if (!this.frameworkEnabled(framework)) throw this.disabledError(framework);
if (typeof request.idempotencyKey !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(request.idempotencyKey)) {
throw new RepositoryValidationError('idempotencyKey must contain 8 to 128 safe characters.');
}
@ -226,8 +336,9 @@ export class RegulatoryMappingService {
const candidate: RegulatoryMappingBatchRun = {
version: 1,
id: `regulatory-mapping-batch-${randomUUID()}`,
...frameworkField(framework),
projectId,
scopeKey: this.batchScopeKey(projectId, scope),
scopeKey: this.batchScopeKey(projectId, scope, framework),
filterHash: sha256(canonicalJson(scope.filters)),
assessmentIds: scope.assessmentIds,
scanRunIds: scope.scanRunIds,
@ -244,19 +355,21 @@ export class RegulatoryMappingService {
assessmentIds: run.assessmentIds,
scanRunIds: run.scanRunIds,
filters: run.filters
});
}, framework);
}
async decide(projectId: string, request: RegulatoryMappingDecisionCreateRequest): Promise<RegulatoryMappingResponse> {
if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503);
const framework = requestedFramework(request.framework);
if (!this.frameworkEnabled(framework)) throw this.disabledError(framework);
const finding = await this.finding(projectId, request.resultProjectionId);
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
const ruleKey = this.ruleKey(finding);
const comment = request.comment?.trim();
const mappingId = await this.groundingMappingId(ruleKey, request.controlId);
const mappingId = await this.groundingMappingId(ruleKey, request.controlId, await this.pipeline(framework));
const decision: RegulatoryMappingDecision = {
version: 1,
id: `regulatory-decision-${randomUUID()}`,
...frameworkField(framework),
ruleKey,
sourceTool: finding.sourceTool ?? 'unknown',
...(finding.sourceRuleId ? { sourceRuleId: finding.sourceRuleId } : {}),
@ -267,7 +380,102 @@ export class RegulatoryMappingService {
createdAt: this.now().toISOString()
};
await this.store.createRegulatoryMappingDecision(decision, request.expectedLatestDecisionId);
return this.get(projectId, request.resultProjectionId);
return this.get(projectId, request.resultProjectionId, framework);
}
/** The consistency of one rule with whether exactly this result was kept by `Mark reviewed`. */
private async ruleConsistency(ruleKey: string, acceptedIso: string[], acceptedBsi: string[], reviews?: Map<string, Set<string>>) {
const { crosswalk } = await this.bsiCatalogs();
const consistency = bsiConsistency(acceptedIso, acceptedBsi, crosswalk);
if (!consistency) return undefined;
const fingerprint = sha256(bsiConsistencyFingerprintSource(acceptedIso, acceptedBsi, crosswalk.version));
const reviewed = reviews ? reviews.get(ruleKey)?.has(fingerprint) ?? false
: (await this.store.listRegulatoryConsistencyReviews()).some(review => review.ruleKey === ruleKey && review.fingerprint === fingerprint);
return { ...consistency, fingerprint, reviewed };
}
/**
* The Compliance tab review list (Phase 13F, BSI stage 2, user decision 2026-10-09): every rule of the
* timeslot's mappable deviations whose accepted ISO controls and directly accepted BSI requirements can be
* compared. Derived only; nothing here changes a decision. Rules are named by one Result DB row of the timeslot.
*/
async consistency(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryConsistencyResponse> {
const scope = await this.validateBatchScope(projectId, { assessmentIds: requested.assessmentIds, scanRunIds: requested.scanRunIds, filters: { deviation: true } });
const results = (await this.queryBatchResults(projectId, scope)).filter(isRegulatoryMappableResult)
.sort((left, right) => left.resultProjectionId.localeCompare(right.resultProjectionId));
const settingIds = await this.gpoSettingIds(projectId, scope, results);
const byRule = new Map<string, { representative: UnifiedResultProjection; identities: Set<string> }>();
for (const finding of results) {
const ruleKey = this.ruleKey(finding);
const entry = byRule.get(ruleKey) ?? { representative: finding, identities: new Set<string>() };
entry.identities.add(findingIdentity(finding, settingIds.get(finding.resultProjectionId)));
byRule.set(ruleKey, entry);
}
const { catalog, crosswalk } = await this.bsiCatalogs();
const requirements = new Map(catalog.requirements.map(requirement => [requirement.id, requirement]));
const reachable = new Set(crosswalk.entries.flatMap(entry => entry.targets));
const reviews = new Map<string, Set<string>>();
for (const review of await this.store.listRegulatoryConsistencyReviews()) reviews.set(review.ruleKey, (reviews.get(review.ruleKey) ?? new Set()).add(review.fingerprint));
// ISO control titles and retrieved candidates from the latest succeeded ISO run of each rule.
const isoRuns = new Map<string, RegulatoryMappingRun>();
for (const run of await this.store.listRegulatoryMappingRuns()) {
if (!byRule.has(run.ruleKey) || mappingFramework(run) !== 'iso27001' || run.status !== 'succeeded') continue;
const latest = isoRuns.get(run.ruleKey);
if (!latest || run.createdAt > latest.createdAt || run.createdAt === latest.createdAt && run.id > latest.id) isoRuns.set(run.ruleKey, run);
}
const isoPipeline = await this.pipeline('iso27001');
const counts = { consistent: 0, inconsistent: 0, noReference: 0, notCompared: 0, reviewed: 0 };
const items: RegulatoryConsistencyItem[] = [];
for (const [ruleKey, { representative, identities }] of byRule) {
const acceptedIso = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey));
const acceptedBsi = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey, 'bsi-kernel'));
const result = await this.ruleConsistency(ruleKey, acceptedIso, acceptedBsi, reviews);
if (!result) { if (acceptedIso.length) counts.notCompared += 1; continue; }
counts[result.state === 'no-reference' ? 'noReference' : result.state] += 1;
if (result.reviewed) counts.reviewed += 1;
// A reverse candidate is decidable here only where the current ISO run retrieved it, as the decision needs grounding.
const currentIso = this.options.client && isoPipeline.corpusVersion
? await this.store.getRegulatoryMappingRun(this.mappingId(ruleKey, isoPipeline)) : null;
const titles = new Map((isoRuns.get(ruleKey)?.candidates ?? []).map(candidate => [candidate.controlId, candidate.title]));
const derived = new Set(result.derivedRequirementIds);
items.push({
resultProjectionId: representative.resultProjectionId,
title: representative.title,
sourceTool: representative.sourceTool ?? 'unknown',
...(representative.sourceRuleId ? { sourceRuleId: representative.sourceRuleId } : {}),
findingCount: identities.size,
state: result.state,
reviewed: result.reviewed,
iso: [...acceptedIso].sort(byNumber).map(controlId => ({ controlId, ...(titles.get(controlId) ? { title: titles.get(controlId) } : {}) })),
direct: result.directRequirementIds.map(requirementId => ({ requirementId,
...(requirements.get(requirementId) ? { title: requirements.get(requirementId)!.title, practice: requirements.get(requirementId)!.practice } : {}),
derived: derived.has(requirementId), reachable: reachable.has(requirementId) })),
derivedCount: result.derivedRequirementIds.length,
reverse: result.reverseCandidates.map(candidate => ({ controlId: candidate.isoControlId,
...(titles.get(candidate.isoControlId) ? { title: titles.get(candidate.isoControlId) } : {}), requirementIds: candidate.requirementIds,
decidable: currentIso?.status === 'succeeded' && currentIso.candidates.some(item => item.controlId === candidate.isoControlId) }))
});
}
const order = { inconsistent: 0, 'no-reference': 1, consistent: 2 } as const;
items.sort((left, right) => Number(left.reviewed) - Number(right.reviewed) || order[left.state] - order[right.state]
|| right.findingCount - left.findingCount || left.title.localeCompare(right.title) || left.resultProjectionId.localeCompare(right.resultProjectionId));
return { version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds },
crosswalkVersion: crosswalk.version, catalogVersion: catalog.version, counts, items };
}
/** Records that a person keeps the current ISO <-> BSI result of the finding's rule as it is. */
async markConsistencyReviewed(projectId: string, request: RegulatoryConsistencyReviewCreateRequest): Promise<{ reviewed: true }> {
const finding = await this.finding(projectId, request?.resultProjectionId);
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
const ruleKey = this.ruleKey(finding);
const acceptedIso = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey));
const acceptedBsi = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey, 'bsi-kernel'));
const result = await this.ruleConsistency(ruleKey, acceptedIso, acceptedBsi);
if (!result) throw new RepositoryValidationError('This rule has no accepted BSI requirement to compare.');
const comment = typeof request.comment === 'string' ? request.comment.trim() : '';
if (!result.reviewed) await this.store.createRegulatoryConsistencyReview({ version: 1, id: `regulatory-consistency-review-${randomUUID()}`,
ruleKey, fingerprint: result.fingerprint, ...(comment ? { comment } : {}), createdAt: this.now().toISOString() });
return { reviewed: true };
}
/** BSI Kernel requirements derived from accepted ISO controls through the active BSI mapping (Phase 13F, stage 1). */
@ -330,7 +538,7 @@ export class RegulatoryMappingService {
const mapped = new Set<string>();
const latestRun = new Map<string, RegulatoryMappingRun>();
for (const run of await this.store.listRegulatoryMappingRuns()) {
if (!byRule.has(run.ruleKey)) continue;
if (!byRule.has(run.ruleKey) || mappingFramework(run) !== 'iso27001') continue;
const latest = latestRun.get(run.ruleKey);
if (!latest || run.createdAt > latest.createdAt || run.createdAt === latest.createdAt && run.id > latest.id) latestRun.set(run.ruleKey, run);
if (run.status !== 'succeeded') continue;
@ -398,8 +606,9 @@ export class RegulatoryMappingService {
const viewedTimeslot = new Set(viewed.timeslot);
const batchRuns = (await Promise.all((await this.read.listProjects())
.map(project => this.store.listRegulatoryMappingBatchRuns(project.id)))).flat();
// User decision 2026-10-09: the history lists ISO and direct BSI mappings, each named by its framework.
const batches: RegulatoryMappingHistoryEntry[] = batchRuns.map(run => ({
id: run.id, kind: 'batch',
id: run.id, kind: 'batch', framework: mappingFramework(run),
scope: run.projectId !== projectId ? 'other-client'
: run.assessmentIds.some(id => assessments.has(id)) || run.scanRunIds.some(id => scanRuns.has(id)) ? 'timeslot' : 'other-timeslot',
trigger: AUTO_MAPPING_BATCH_KEY_PREFIXES.some(prefix => run.idempotencyKey.startsWith(prefix)) ? 'automatic' : 'manual',
@ -412,7 +621,7 @@ export class RegulatoryMappingService {
const singles: RegulatoryMappingHistoryEntry[] = (await this.store.listRegulatoryMappingRuns())
.filter(run => run.trigger === 'single')
.map(run => ({
id: run.id, kind: 'single', trigger: 'manual',
id: run.id, kind: 'single', framework: mappingFramework(run), trigger: 'manual',
scope: !run.origin ? 'unknown' : run.origin.timeslot.some(token => viewedTimeslot.has(token)) ? 'timeslot'
: run.origin.project === viewed.project ? 'other-timeslot' : 'other-client',
status: run.status === 'succeeded' ? 'completed' : run.status,
@ -425,17 +634,19 @@ export class RegulatoryMappingService {
}
/**
* Accepts the first proposals of a finished mapping, in the model's order. A control that already has a
* Accepts the first two strong proposals of a finished mapping, in the model's order. A control that already has a
* decision keeps it, so a manual reject is never overridden; a failure here never fails the mapping.
*/
private async autoAcceptFirstProposals(run: RegulatoryMappingRun, result: RegulatoryMappingResult): Promise<void> {
if (result.outcome !== 'mapped') return;
try {
const decided = new Set((await this.store.listRegulatoryMappingDecisions(run.ruleKey)).map(item => item.controlId));
for (const proposal of result.mappings.slice(0, AUTO_ACCEPTED_PROPOSALS)) {
// User decision 2026-10-09: direct BSI mappings are accepted automatically like ISO ones, for now.
const framework = mappingFramework(run);
const decided = new Set((await this.store.listRegulatoryMappingDecisions(run.ruleKey, framework)).map(item => item.controlId));
for (const proposal of result.mappings.filter(isStrongProposal).slice(0, AUTO_ACCEPTED_PROPOSALS)) {
if (decided.has(proposal.controlId)) continue;
await this.store.createRegulatoryMappingDecision({
version: 1, id: `regulatory-decision-${randomUUID()}`, ruleKey: run.ruleKey, sourceTool: run.sourceTool,
version: 1, id: `regulatory-decision-${randomUUID()}`, ...frameworkField(framework), ruleKey: run.ruleKey, sourceTool: run.sourceTool,
...(run.sourceRuleId ? { sourceRuleId: run.sourceRuleId } : {}), mappingId: run.id, controlId: proposal.controlId,
decision: 'accepted', comment: AUTO_ACCEPT_COMMENT, createdAt: this.now().toISOString()
});
@ -456,8 +667,10 @@ export class RegulatoryMappingService {
// Deterministic id per (ruleKey, pipeline versions) so one row exists per rule per
// version tuple: regeneration reuses it, and a version bump yields a new mapping.
private mappingId(ruleKey: string): string {
const tuple = [ruleKey, this.options.corpusVersion, REGULATORY_RETRIEVAL_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, this.options.client!.model,
// ISO ids keep their earlier tuple; a BSI id also names its framework.
private mappingId(ruleKey: string, pipeline: MappingPipeline): string {
const tuple = [...(pipeline.framework === 'bsi-kernel' ? [pipeline.framework] : []),
ruleKey, pipeline.corpusVersion, pipeline.retrievalVersion, pipeline.promptVersion, this.options.client!.model,
// GPO rules carry the baseline context of their query version; scanner mapping ids stay unchanged.
...(isGpoRuleKey(ruleKey) ? [REGULATORY_GPO_QUERY_VERSION] : [])].join('');
return `regulatory-mapping-${createHash('sha256').update(tuple, 'utf8').digest('hex').slice(0, 40)}`;
@ -465,8 +678,8 @@ export class RegulatoryMappingService {
// The stored prompt version must differ wherever the id differs: the table's unique
// identity tuple has no column for the GPO query version, so GPO runs record it here.
private promptVersion(ruleKey: string): string {
return isGpoRuleKey(ruleKey) ? `${REGULATORY_MAPPING_PROMPT_VERSION}+${REGULATORY_GPO_QUERY_VERSION}` : REGULATORY_MAPPING_PROMPT_VERSION;
private promptVersion(ruleKey: string, pipeline: MappingPipeline): string {
return isGpoRuleKey(ruleKey) ? `${pipeline.promptVersion}+${REGULATORY_GPO_QUERY_VERSION}` : pipeline.promptVersion;
}
async close(): Promise<void> {
@ -495,26 +708,27 @@ export class RegulatoryMappingService {
}
}
private queuedRun(finding: UnifiedResultProjection, trigger: 'single' | 'batch', origin?: RegulatoryMappingRun['origin']): RegulatoryMappingRun {
private queuedRun(finding: UnifiedResultProjection, trigger: 'single' | 'batch', pipeline: MappingPipeline, origin?: RegulatoryMappingRun['origin']): RegulatoryMappingRun {
const preparedQuery = buildRegulatoryFindingQuery(finding);
if (!preparedQuery) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
const timestamp = this.now().toISOString();
const ruleKey = this.ruleKey(finding);
return {
version: 1,
id: this.mappingId(ruleKey),
id: this.mappingId(ruleKey, pipeline),
...frameworkField(pipeline.framework),
ruleKey,
sourceTool: finding.sourceTool ?? 'unknown',
...(finding.sourceRuleId ? { sourceRuleId: finding.sourceRuleId } : {}),
status: 'queued',
queryHash: sha256(JSON.stringify(preparedQuery)),
preparedQuery,
corpusId: this.options.corpusId!,
corpusVersion: this.options.corpusVersion!,
corpusId: pipeline.corpusId,
corpusVersion: pipeline.corpusVersion,
sourceHashes: [],
embeddingModel: this.options.embeddingModel!,
retrievalVersion: REGULATORY_RETRIEVAL_VERSION,
promptVersion: this.promptVersion(ruleKey),
retrievalVersion: pipeline.retrievalVersion,
promptVersion: this.promptVersion(ruleKey, pipeline),
outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION,
model: this.options.client!.model,
trigger,
@ -525,8 +739,9 @@ export class RegulatoryMappingService {
};
}
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean, trigger: 'single' | 'batch', origin?: RegulatoryMappingRun['origin']) {
return this.store.claimRegulatoryMappingRun(this.queuedRun(finding, trigger, origin), forceRegeneration);
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean, trigger: 'single' | 'batch', pipeline: MappingPipeline,
origin?: RegulatoryMappingRun['origin']) {
return this.store.claimRegulatoryMappingRun(this.queuedRun(finding, trigger, pipeline, origin), forceRegeneration);
}
/** The client always; the timeslot only when it is a valid timeslot of that client. */
@ -555,8 +770,9 @@ export class RegulatoryMappingService {
return scope;
}
private batchScopeKey(projectId: string, scope: RegulatoryMappingBatchScope): string {
return sha256(canonicalJson({ projectId, ...scope }));
// ISO keys keep their earlier input, so an active ISO batch and a BSI batch of the same scope never collide.
private batchScopeKey(projectId: string, scope: RegulatoryMappingBatchScope, framework: RegulatoryFramework = 'iso27001'): string {
return sha256(canonicalJson({ projectId, ...scope, ...frameworkField(framework) }));
}
private async queryBatchResults(projectId: string, scope: RegulatoryMappingBatchScope): Promise<UnifiedResultProjection[]> {
@ -628,15 +844,16 @@ export class RegulatoryMappingService {
counts.unsupportedFindingCount = unsupported.size;
counts.distinctRuleCount = representatives.size;
const scheduled: Array<Promise<'succeeded' | 'failed'>> = [];
const pipeline = await this.pipeline(mappingFramework(run));
for (const finding of representatives.values()) {
const claimed = await this.claim(finding, false, 'batch');
const claimed = await this.claim(finding, false, 'batch', pipeline);
if (!claimed.claimed) {
if (claimed.run.status === 'succeeded') counts.reusedSucceededRuleCount += 1;
else counts.reusedActiveRuleCount += 1;
continue;
}
counts.scheduledRuleCount += 1;
const completion = this.enqueue(() => this.process(claimed.run, finding));
const completion = this.enqueue(() => this.process(claimed.run, finding, pipeline));
this.track(completion.then(() => undefined));
scheduled.push(completion);
}
@ -669,7 +886,11 @@ export class RegulatoryMappingService {
}
}
private async availability(): Promise<{ enabled: boolean; reason?: string }> {
private async availability(framework: RegulatoryFramework = 'iso27001'): Promise<{ enabled: boolean; reason?: string }> {
if (framework === 'bsi-kernel') {
// The BSI corpus is built from the bundled or updated catalog when the first BSI mapping needs it.
return this.frameworkEnabled(framework) ? { enabled: true } : { enabled: false, reason: this.disabledError(framework).message };
}
if (!this.generationEnabled) return { enabled: false, reason: this.options.disabledReason ?? 'Regulatory mapping is not configured.' };
try {
const sample = await this.options.index!.list({
@ -692,20 +913,53 @@ export class RegulatoryMappingService {
return finding;
}
private async groupCandidates(hits: RegulatoryRetrievalCandidate[]): Promise<RegulatoryMappingCandidate[]> {
const byControl = new Map<string, RegulatoryRetrievalCandidate[]>();
for (const hit of hits) {
if (!hit.chunk.annexAControlId || !hit.chunk.sectionKind || !hit.chunk.sourcePageStart || !hit.chunk.sourcePageEnd || !hit.chunk.sourceSha256) continue;
const values = byControl.get(hit.chunk.controlId) ?? [];
values.push(hit);
byControl.set(hit.chunk.controlId, values);
}
const selected = [...byControl.entries()]
.map(([controlId, values]) => ({ controlId, values: values.sort((left, right) => right.score - left.score), score: Math.max(...values.map(value => value.score)) }))
.sort((left, right) => right.score - left.score || left.controlId.localeCompare(right.controlId, 'en', { numeric: true }))
.slice(0, 8);
/**
* The further ranked lists of the hybrid search (user decision 2026-10-09): a dense search with a German restatement
* of the finding and a keyword search with its German terms. The norm texts are German, so an English query alone
* matches them weakly. Without a German restatement the mapping keeps the English dense search only.
*/
private async hybridRankings(query: RegulatoryFindingQuery, pipeline: MappingPipeline, limit: number) {
const expansion = this.options.queryExpansionClient ? await this.expandQuery(query) : undefined;
if (!expansion) return { lists: [] as RegulatoryRetrievalCandidate[][] };
const german = `${expansion.query} ${expansion.terms.join(' ')}`;
const lists = [await this.options.findingRetrieval!.retrieveText(german, limit, retrievalFilters(pipeline))];
const lexical = await this.lexicalIndex(pipeline);
if (lexical) lists.push(lexical.search(german, limit));
return { expansion: { ...expansion, searches: lexical ? ['dense-en', 'dense-de', 'keyword-de'] : ['dense-en', 'dense-de'] }, lists };
}
/** One German restatement per public query, shared by the ISO and BSI mapping of a rule; a failure is retried next time. */
private expandQuery(query: RegulatoryFindingQuery): Promise<Omit<RegulatoryQueryExpansion, 'searches'> | undefined> {
const key = sha256(query.query);
const cached = this.expansions.get(key);
if (cached) return cached;
const task = this.options.queryExpansionClient!.complete(QUERY_EXPANSION_PROMPT, query.query)
.then(completion => queryExpansion(completion.value)).catch(() => undefined);
this.expansions.set(key, task);
void task.then(value => { if (!value) this.expansions.delete(key); });
while (this.expansions.size > MAXIMUM_CACHED_EXPANSIONS) this.expansions.delete(this.expansions.keys().next().value!);
return task;
}
/** The keyword index of one corpus version, built once from Qdrant; unavailable without a full listing. */
private lexicalIndex(pipeline: MappingPipeline): Promise<RegulatoryLexicalIndex | undefined> {
const key = `${pipeline.corpusId}\u0000${pipeline.corpusVersion}`;
const cached = this.lexicalIndexes.get(key);
if (cached) return cached;
const listAll = this.options.index!.listAll?.bind(this.options.index);
const task = listAll ? listAll(retrievalFilters(pipeline)).then(chunks => new RegulatoryLexicalIndex(chunks)).catch(() => undefined) : Promise.resolve(undefined);
this.lexicalIndexes.set(key, task);
void task.then(value => { if (!value) this.lexicalIndexes.delete(key); });
return task;
}
/** The eight best ISO controls of the fused ranking, each with its 27001 statement and full 27002 text. */
private async groupCandidates(fused: FusedControl[]): Promise<RegulatoryMappingCandidate[]> {
const usable = (chunk: RegulatoryChunk) => Boolean(chunk.annexAControlId && chunk.sectionKind && chunk.sourcePageStart && chunk.sourcePageEnd && chunk.sourceSha256);
const selected = fused.map(control => ({ ...control, chunks: control.chunks.filter(usable) }))
.filter(control => control.chunks.length > 0).slice(0, ISO_CANDIDATE_LIMIT);
return Promise.all(selected.map(async selection => {
const semantic = selection.values.slice(0, 2).map(value => evidence(value.chunk));
const semantic = await this.fullControlText(selection.controlId, selection.chunks);
const references = await this.options.index!.list({
corpusId: this.options.corpusId,
corpusVersion: this.options.corpusVersion,
@ -716,49 +970,100 @@ export class RegulatoryMappingService {
if (references.length !== 1 || !references[0].sourceSha256) {
throw new RegulatoryMappingServiceError('regulatory_reference_missing', `The active corpus is missing the ISO 27001 reference for ${selection.controlId}.`, 503);
}
const first = selection.values[0].chunk;
const first = selection.chunks[0];
return {
controlId: selection.controlId,
annexAControlId: first.annexAControlId!,
title: first.title,
score: selection.score,
evidence: [...semantic, ...references.map(evidence)]
evidence: [...references.map(evidence), ...semantic]
};
}));
}
private async process(queued: RegulatoryMappingRun, finding: UnifiedResultProjection): Promise<'succeeded' | 'failed'> {
/**
* The ISO/IEC 27002 text of one candidate control for the model (prompt 1.3.0, user decision 2026-10-09): control,
* purpose and guidance in document order instead of only the two best-matching passages, then other information
* while it fits. Retrieval only selects the control. The passages that matched are always kept; the rest stops at
* a per-control cap, so a very long control cannot crowd out the other candidates.
*/
private async fullControlText(controlId: string, matched: RegulatoryChunk[]): Promise<RegulatoryMappingEvidence[]> {
const listed = await this.options.index!.list({ corpusId: this.options.corpusId, corpusVersion: this.options.corpusVersion,
controlId, sourceStandard: 'ISO/IEC 27002' }, 100);
const usable = (chunk: RegulatoryChunk) => chunk.controlId === controlId && chunk.sourceStandard === 'ISO/IEC 27002'
&& Boolean(chunk.sectionKind && chunk.sourcePageStart && chunk.sourcePageEnd && chunk.sourceSha256);
const byId = new Map([...listed, ...matched].filter(usable).map(chunk => [chunk.chunkId, chunk]));
const ordered = [...byId.values()].sort((left, right) => SECTION_ORDER.indexOf(left.sectionKind!) - SECTION_ORDER.indexOf(right.sectionKind!)
|| left.documentId.localeCompare(right.documentId) || left.chunkIndex - right.chunkIndex);
const kept = new Set(matched.filter(usable).map(chunk => chunk.chunkId));
let length = [...kept].reduce((total, id) => total + byId.get(id)!.text.length, 0);
// Core sections first, other information only with the space left.
for (const pass of [CORE_SECTIONS, ['other-information']] as const) {
for (const chunk of ordered) {
if (kept.has(chunk.chunkId) || !pass.includes(chunk.sectionKind as never)) continue;
if (length + chunk.text.length > MAXIMUM_CONTROL_TEXT_CHARACTERS) continue;
kept.add(chunk.chunkId);
length += chunk.text.length;
}
}
return ordered.filter(chunk => kept.has(chunk.chunkId)).map(evidence);
}
/**
* The best Kernel requirements of the fused ranking, each with every chunk of its requirement as evidence,
* so the model always reads the statement even when only its guidance was retrieved.
*/
private async groupBsiCandidates(fused: FusedControl[], corpusVersion: string): Promise<RegulatoryMappingCandidate[]> {
const { catalog } = await this.bsiCatalogs();
const known = new Map(catalog.requirements.map(requirement => [requirement.id, requirement]));
const selected = fused.filter(control => known.has(control.controlId) && control.chunks.some(chunk => chunk.corpusId === BSI_CORPUS_ID))
.slice(0, BSI_CANDIDATE_LIMIT).map(control => [control.controlId, control.score] as const);
return Promise.all(selected.map(async ([controlId, score]) => {
const chunks = (await this.options.index!.list({ corpusId: BSI_CORPUS_ID, corpusVersion, controlId }, 3))
.sort((left, right) => left.chunkIndex - right.chunkIndex);
if (!chunks.length) throw new RegulatoryMappingServiceError('regulatory_reference_missing', `The BSI corpus is missing requirement ${controlId}.`, 503);
return { controlId, annexAControlId: controlId, title: known.get(controlId)!.title, score,
evidence: chunks.map(chunk => bsiEvidence(chunk, catalog.sourceSha256)) };
}));
}
private async process(queued: RegulatoryMappingRun, finding: UnifiedResultProjection, pipeline: MappingPipeline): Promise<'succeeded' | 'failed'> {
let run = queued;
const bsi = pipeline.framework === 'bsi-kernel';
try {
let retrieval;
try {
retrieval = await this.options.findingRetrieval!.retrieve(finding, 32, {
corpusId: this.options.corpusId,
corpusVersion: this.options.corpusVersion,
sourceStandard: 'ISO/IEC 27002',
retrievalRole: 'semantic'
});
// The BSI corpus is indexed from the active catalog the first time a BSI mapping needs it.
if (bsi && await this.options.bsiCorpus!.ensure() !== pipeline.corpusVersion) {
throw new Error('The BSI catalog changed while the mapping was queued.');
}
retrieval = await this.options.findingRetrieval!.retrieve(finding, bsi ? 40 : 32, retrievalFilters(pipeline));
} catch {
throw new RegulatoryMappingServiceError('regulatory_retrieval_unavailable', 'Regulatory retrieval is currently unavailable.', 503);
}
if (retrieval.status !== 'candidates' || !retrieval.preparedQuery) {
throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
}
const candidates = await this.groupCandidates(retrieval.candidates);
const hybrid = await this.hybridRankings(retrieval.preparedQuery, pipeline, bsi ? 40 : 32);
// The run records the German restatement it searched with.
const preparedQuery: RegulatoryFindingQuery = hybrid.expansion ? { ...retrieval.preparedQuery, expansion: hybrid.expansion } : retrieval.preparedQuery;
// BSI only: procurement, service providers, buildings and personnel are not the subject of a technical finding.
const fused = fuseControlRankings([retrieval.candidates, ...hybrid.lists], bsi ? controlId => BSI_EXCLUDED_PRACTICES.test(controlId) : undefined);
const candidates = bsi ? await this.groupBsiCandidates(fused, pipeline.corpusVersion) : await this.groupCandidates(fused);
if (!candidates.length) throw new RegulatoryMappingServiceError('regulatory_corpus_empty', 'The active regulatory corpus returned no eligible controls.', 503);
const startedAt = this.now().toISOString();
run = await this.store.saveRegulatoryMappingRun({
...queued,
status: 'running',
preparedQuery: retrieval.preparedQuery,
queryHash: sha256(JSON.stringify(retrieval.preparedQuery)),
preparedQuery,
queryHash: sha256(JSON.stringify(preparedQuery)),
sourceHashes: [...new Set(candidates.flatMap(candidate => candidate.evidence.map(evidence => evidence.sourceSha256)))].sort(),
candidates,
startedAt,
updatedAt: startedAt
});
const prompt = mappingPrompt(run);
const completion = await this.options.client!.complete(SYSTEM_PROMPT, prompt);
const completion = await this.options.client!.complete(bsi ? BSI_SYSTEM_PROMPT : SYSTEM_PROMPT, prompt);
const allowed = new Map(run.candidates.map(candidate => [
candidate.controlId, new Set(candidate.evidence.map(item => item.chunkId))
]));
@ -820,16 +1125,27 @@ function evidence(chunk: RegulatoryRetrievalCandidate['chunk']): RegulatoryMappi
};
}
function bsiEvidence(chunk: RegulatoryChunk, sourceSha256: string): RegulatoryMappingEvidence {
return { chunkId: chunk.chunkId, textSha256: chunk.textSha256, sourceSha256, sourceStandard: BSI_SOURCE_STANDARD, sectionKind: 'control', text: chunk.text };
}
function mappingPrompt(run: RegulatoryMappingRun): string {
// The Annex A id is always "A." + controlId, so it is redundant for the model and
// sending it invites a reply that uses the Annex A form; expose only the controlId it must return.
// A BSI candidate repeats its requirement id there, and the BSI evidence has no pages.
const candidates = run.candidates.map(({ annexAControlId, ...candidate }) => candidate);
const payload = JSON.stringify({ finding: run.preparedQuery, candidates });
if (payload.length > MAXIMUM_PROMPT_CHARACTERS) throw new RegulatoryMappingServiceError('regulatory_mapping_prompt_too_large', 'The bounded regulatory prompt exceeded its limit.', 422);
return payload;
}
const SYSTEM_PROMPT = `Map one public security finding query to the supplied ISO control candidates. A mapping means the control is topically relevant to the finding, never proof of compliance. Map a candidate when it governs the same security subject as the finding (for example password or authentication management, logging, or access restriction), even when the control text does not state the finding's specific value, threshold, parameter, or configuration; ISO controls are principle-level and rarely restate such specifics. Judge relevance from the control's subject and its supplied evidence, not from whether the exact requirement text appears. Return JSON only with schemaVersion 1, outcome mapped|no_relevant_control|insufficient_context, optional bounded reason, and mappings (maximum five). Each mapping requires controlId, relationship primary|supporting, confidence low|medium|high, a concise rationale under 600 characters, and one to three evidenceChunkIds copied only from that control's supplied evidence. Use no_relevant_control only when no candidate governs the finding's subject, and insufficient_context only when the finding query itself lacks enough meaning to judge. Do not invent controls or evidence.`;
const SYSTEM_PROMPT = `Map one public security finding query to the supplied ISO control candidates. Each candidate carries its ISO/IEC 27001 control statement and the ISO/IEC 27002 control, purpose and guidance text of that control in document order; read the whole text before judging. A mapping means the control is topically relevant to the finding, never proof of compliance. Map a candidate when it governs the same security subject as the finding (for example password or authentication management, logging, or access restriction), even when the control text does not state the finding's specific value, threshold, parameter, or configuration; ISO controls are principle-level and rarely restate such specifics. Judge relevance from the control's subject and its supplied evidence, not from whether the exact requirement text appears. Return JSON only with schemaVersion 1, outcome mapped|no_relevant_control|insufficient_context, optional bounded reason, and mappings (maximum five). Each mapping requires controlId, relationship primary|supporting, confidence low|medium|high, a concise rationale under 600 characters, and one to three evidenceChunkIds copied only from that control's supplied evidence. Use no_relevant_control only when no candidate governs the finding's subject, and insufficient_context only when the finding query itself lacks enough meaning to judge. Do not invent controls or evidence.`;
const BSI_SYSTEM_PROMPT = `Map one public security finding query to the supplied BSI Grundschutz++ Kernel requirement candidates. The requirements are published in German; MUSS, SOLLTE and KANN state their obligation level. A mapping means the requirement is topically relevant to the finding, never proof of compliance. Map a candidate when it governs the same security subject as the finding (for example authentication, hardening of a configuration, logging, or restriction of access), even when the requirement does not restate the finding's specific value, threshold, parameter, or configuration. Prefer the requirements that address the finding's specific subject over general management or process requirements. Judge relevance from the requirement's subject and its supplied evidence. Return JSON only with schemaVersion 1, outcome mapped|no_relevant_control|insufficient_context, optional bounded reason, and mappings (maximum five). Each mapping requires controlId (the requirement id exactly as supplied, for example KONF.2.1), relationship primary|supporting, confidence low|medium|high, a concise English rationale under 600 characters, and one to three evidenceChunkIds copied only from that requirement's supplied evidence. Use no_relevant_control only when no candidate governs the finding's subject, and insufficient_context only when the finding query itself lacks enough meaning to judge. Do not invent requirements or evidence.`;
function byNumber(left: string, right: string): number {
return left.localeCompare(right, 'en', { numeric: true });
}
function safeFailure(error: unknown): string {
if (error instanceof RegulatoryMappingServiceError || error instanceof LiteLlmClientError) return error.code;

View File

@ -152,6 +152,7 @@ async function main(): Promise<void> {
regulatoryMappingService: complianceEngine.mappingService,
regulatoryCorpusUploadService: complianceEngine.corpusUploadService,
bsiCatalogService: complianceEngine.bsiCatalogService,
bsiSearchIndex: complianceEngine.bsiCorpus,
complianceExport: complianceEngine.store,
isoUploadToken: process.env.ASSESSOR_ISO_UPLOAD_TOKEN?.trim(),
automaticGpoAnalysis,

View File

@ -24,6 +24,7 @@ import aiOverviewRuns from './migrations/025_ai_overview_runs.sql?raw';
import regulatoryMapping from './migrations/026_regulatory_mapping.sql?raw';
import vulnerabilityFindingResultType from './migrations/027_vulnerability_finding_result_type.sql?raw';
import scopedRiskAcceptances from './migrations/028_scoped_risk_acceptances.sql?raw';
import regulatoryMappingFramework from './migrations/029_regulatory_mapping_framework.sql?raw';
export interface DatabaseMigration {
version: string;
@ -57,5 +58,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '025', name: 'ai-overview-runs', sql: aiOverviewRuns },
{ version: '026', name: 'regulatory-mapping', sql: regulatoryMapping },
{ version: '027', name: 'vulnerability-finding-result-type', sql: vulnerabilityFindingResultType },
{ version: '028', name: 'scoped-risk-acceptances', sql: scopedRiskAcceptances }
{ version: '028', name: 'scoped-risk-acceptances', sql: scopedRiskAcceptances },
{ version: '029', name: 'regulatory-mapping-framework', sql: regulatoryMappingFramework }
];

View File

@ -0,0 +1,29 @@
-- Phase 13F, BSI Grundschutz++ stage 2 (user decision 2026-10-09): the Compliance Engine maps rules to ISO
-- controls and, separately, directly to BSI Kernel requirements. Mappings and decisions name their framework;
-- every existing row is an ISO mapping. Consistency reviews record that a person kept an ISO <-> BSI
-- disagreement on purpose, for one fingerprint of the compared sets. All three tables belong to the engine.
ALTER TABLE assessor_regulatory_mappings
ADD COLUMN framework text NOT NULL DEFAULT 'iso27001' CHECK (framework IN ('iso27001', 'bsi-kernel'));
ALTER TABLE assessor_regulatory_mappings DROP CONSTRAINT assessor_regulatory_mappings_identity_uq;
ALTER TABLE assessor_regulatory_mappings ADD CONSTRAINT assessor_regulatory_mappings_identity_uq
UNIQUE (framework, rule_key, corpus_version, retrieval_version, prompt_version, model);
ALTER TABLE assessor_regulatory_mapping_decisions
ADD COLUMN framework text NOT NULL DEFAULT 'iso27001' CHECK (framework IN ('iso27001', 'bsi-kernel'));
DROP INDEX assessor_regulatory_mapping_decisions_rule_control_idx;
CREATE INDEX assessor_regulatory_mapping_decisions_rule_control_idx
ON assessor_regulatory_mapping_decisions (rule_key, framework, control_id, created_at DESC, id DESC);
CREATE TABLE assessor_regulatory_consistency_reviews (
id text PRIMARY KEY,
rule_key text NOT NULL,
fingerprint text NOT NULL CHECK (fingerprint ~ '^[a-f0-9]{64}$'),
created_at timestamptz NOT NULL,
data jsonb NOT NULL
);
CREATE INDEX assessor_regulatory_consistency_reviews_rule_idx
ON assessor_regulatory_consistency_reviews (rule_key, created_at DESC, id DESC);

View File

@ -384,6 +384,7 @@ const DB_STRUCTURE_LAYER_BY_TABLE: Record<string, DbStructureLayerId> = {
assessor_regulatory_mappings: 'result',
assessor_regulatory_mapping_decisions: 'governance',
assessor_regulatory_mapping_batches: 'governance',
assessor_regulatory_consistency_reviews: 'governance',
assessor_ai_overview_runs: 'governance',
assessor_finding_occurrences: 'result',
assessor_result_categories: 'result',

View File

@ -17,7 +17,9 @@ import type { ClientInformationQuery, ClientInformationResult } from '../domain/
import type { ImportedDataset } from '../domain/dataset';
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
import type { RegulatoryCorpusStatus } from '../compliance/regulatory-corpus-management';
import type { BsiCatalogRefreshResult, BsiCatalogState } from '../compliance/bsi-catalog-management';
import type { RegulatoryFramework } from '../compliance/regulatory-mapping';
import type { RegulatoryConsistencyResponse, RegulatoryConsistencyReviewCreateRequest } from '../compliance/bsi-consistency';
import type { BsiCatalogRefreshResult, BsiCatalogState, BsiSearchIndexStatus } from '../compliance/bsi-catalog-management';
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
import {
@ -107,6 +109,11 @@ export interface AssessorApiClientOptions {
describeActivity?: typeof requestActivity;
}
/** ISO requests keep their earlier URL; only another framework is named. */
function frameworkQuery(framework: RegulatoryFramework, separator: '?' | '&'): string {
return framework === 'iso27001' ? '' : `${separator}framework=${encodeURIComponent(framework)}`;
}
function unifiedQueryPath(path: string, query: UnifiedResultQuery = {}): string {
const parameters = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
@ -237,8 +244,8 @@ export class AssessorApiClient {
});
}
getRegulatoryMappings(projectId: string, resultProjectionId: string): Promise<RegulatoryMappingResponse> {
return this.request(`/projects/${encodeURIComponent(projectId)}/regulatory-mappings/${encodeURIComponent(resultProjectionId)}`);
getRegulatoryMappings(projectId: string, resultProjectionId: string, framework: RegulatoryFramework = 'iso27001'): Promise<RegulatoryMappingResponse> {
return this.request(`/projects/${encodeURIComponent(projectId)}/regulatory-mappings/${encodeURIComponent(resultProjectionId)}${frameworkQuery(framework, '?')}`);
}
generateRegulatoryMappings(projectId: string, request: RegulatoryMappingCreateRequest): Promise<RegulatoryMappingResponse> {
@ -253,14 +260,28 @@ export class AssessorApiClient {
});
}
getRegulatoryMappingBatch(projectId: string, scope: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchResponse> {
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/regulatory-mapping-batches`, {
getRegulatoryMappingBatch(projectId: string, scope: RegulatoryMappingBatchScope, framework: RegulatoryFramework = 'iso27001'): Promise<RegulatoryMappingBatchResponse> {
const path = unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/regulatory-mapping-batches`, {
...scope.filters,
assessmentIds: scope.assessmentIds,
scanRunIds: scope.scanRunIds
});
return this.request(`${path}${frameworkQuery(framework, path.includes('?') ? '&' : '?')}`);
}
getRegulatoryMappingConsistency(projectId: string, scope: { assessmentIds: string[]; scanRunIds: string[] }): Promise<RegulatoryConsistencyResponse> {
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/regulatory-mapping-consistency`, {
assessmentIds: scope.assessmentIds,
scanRunIds: scope.scanRunIds
}));
}
markRegulatoryMappingConsistencyReviewed(projectId: string, request: RegulatoryConsistencyReviewCreateRequest): Promise<{ reviewed: true }> {
return this.request(`/projects/${encodeURIComponent(projectId)}/regulatory-mapping-consistency-reviews`, {
method: 'POST', body: JSON.stringify(request)
});
}
getRegulatoryControlAssignments(projectId: string, scope: { assessmentIds: string[]; scanRunIds: string[] }): Promise<RegulatoryControlOverviewResponse> {
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/regulatory-control-assignments`, {
assessmentIds: scope.assessmentIds,
@ -489,6 +510,14 @@ export class AssessorApiClient {
return this.request('/reference-data/bsi-grundschutz/refresh', { method: 'POST', body: '{}' });
}
getBsiSearchIndex(): Promise<BsiSearchIndexStatus> {
return this.request('/reference-data/bsi-grundschutz/search-index');
}
buildBsiSearchIndex(force: boolean): Promise<BsiSearchIndexStatus> {
return this.request('/reference-data/bsi-grundschutz/search-index/build', { method: 'POST', body: JSON.stringify({ force }) });
}
resetBsiCatalog(): Promise<BsiCatalogState> {
return this.request('/reference-data/bsi-grundschutz/reset', { method: 'POST', body: '{}' });
}

View File

@ -30,12 +30,14 @@ function complianceActivity(route: string, method: string): string | undefined {
const write = method !== 'GET';
if (route.includes('/regulatory-control-assignments')) return 'Loading ISO control filters';
if (route.includes('/regulatory-control-overview')) return 'Loading ISO control overview';
if (route.includes('/regulatory-mapping-batches')) return write ? 'Starting ISO mapping batch' : 'Loading ISO mapping batch status';
if (route.includes('/regulatory-mapping-batches')) return write ? 'Starting compliance mapping batch' : 'Loading compliance mapping batch status';
if (route.includes('/regulatory-mapping-runs')) return 'Generating ISO control proposals';
if (route.includes('/regulatory-mapping-decisions')) return 'Saving ISO mapping decision';
if (route.includes('/regulatory-mappings/')) return 'Loading ISO mapping';
if (route.endsWith('/ai-overview')) return write ? 'Starting AI overview' : 'Loading AI overview status';
if (route.endsWith('/reference-data/iso-corpus')) return write ? 'Uploading ISO corpus' : 'Loading ISO corpus status';
if (route.endsWith('/reference-data/bsi-grundschutz/search-index/build')) return 'Building BSI search index';
if (route.endsWith('/reference-data/bsi-grundschutz/search-index')) return 'Loading BSI search index status';
if (route.endsWith('/reference-data/bsi-grundschutz/refresh')) return 'Updating BSI catalog';
if (route.endsWith('/reference-data/bsi-grundschutz/reset')) return 'Restoring bundled BSI catalog';
if (route.endsWith('/reference-data/bsi-grundschutz')) return 'Loading BSI catalog';

View File

@ -17,3 +17,23 @@ export interface BsiCatalogState {
export interface BsiCatalogRefreshResult extends BsiCatalogState {
updated: boolean;
}
/**
* Data Hub BSI tile: the retrieval index of the active Kernel catalog for direct BSI mappings (user decision
* 2026-10-09). `outdated` means only an index of another catalog state exists; `unavailable` means embedding or
* Qdrant is not configured. A BSI mapping still builds a missing index itself.
*/
export interface BsiSearchIndexStatus {
version: 1;
state: 'unavailable' | 'not-built' | 'outdated' | 'building' | 'built' | 'failed';
catalogVersion: string;
corpusVersion: string;
requirementCount: number;
/** Indexed chunks of the active catalog state; a requirement with long guidance has several. */
indexedChunks: number;
/** While building: embedded chunks of all chunks. */
progress?: { completed: number; total: number };
builtAt?: string;
error?: string;
disabledReason?: string;
}

View File

@ -0,0 +1,130 @@
import type { BsiIsoCrosswalk } from './bsi-grundschutz';
/**
* ISO <-> BSI consistency of one rule (Phase 13F, BSI stage 2, user decision 2026-10-09). The BSI requirements the
* model accepted directly are compared with the requirements the official BSI mapping derives from the accepted
* ISO controls. Agreement shows consistency, not correctness: both mappings come from the same model and finding
* text. The result is derived only; it never changes, rejects or blocks a decision.
*
* - `consistent`: at least one direct requirement lies in the derived set (`subset-of` covers several requirements,
* and a finding rarely hits all of them, so one hit is enough).
* - `inconsistent`: a direct requirement is reachable through the BSI mapping, but none lies in the derived set.
* - `no-reference`: no direct requirement is reachable from any ISO control; the gap the direct mapping fills.
*/
export type BsiConsistencyState = 'consistent' | 'inconsistent' | 'no-reference';
export interface BsiConsistency {
state: BsiConsistencyState;
/** Accepted direct BSI requirements, sorted. */
directRequirementIds: string[];
/** Requirements derived from the accepted ISO controls, sorted. */
derivedRequirementIds: string[];
/** Direct requirements that also lie in the derived set. */
overlapRequirementIds: string[];
/** Direct requirements the BSI mapping reaches from no ISO control. */
unreachableRequirementIds: string[];
/**
* The reverse direction: ISO controls that are not accepted but whose BSI mapping targets contain a direct
* requirement, as candidates the ISO mapping may have missed.
*/
reverseCandidates: Array<{ isoControlId: string; requirementIds: string[] }>;
}
const byNumber = (left: string, right: string) => left.localeCompare(right, 'en', { numeric: true });
/** Undefined while no direct BSI requirement is accepted: then there is nothing to compare. */
export function bsiConsistency(acceptedIsoControlIds: readonly string[], acceptedBsiRequirementIds: readonly string[],
crosswalk: BsiIsoCrosswalk): BsiConsistency | undefined {
const direct = [...new Set(acceptedBsiRequirementIds)].sort(byNumber);
if (!direct.length) return undefined;
const acceptedIso = new Set(acceptedIsoControlIds);
const derived = new Set<string>();
const reachable = new Set<string>();
const reverse = new Map<string, Set<string>>();
const directSet = new Set(direct);
for (const entry of crosswalk.entries) {
for (const target of entry.targets) {
reachable.add(target);
if (acceptedIso.has(entry.isoControlId)) derived.add(target);
else if (directSet.has(target)) reverse.set(entry.isoControlId, (reverse.get(entry.isoControlId) ?? new Set()).add(target));
}
}
const overlap = direct.filter(id => derived.has(id));
const unreachable = direct.filter(id => !reachable.has(id));
return {
state: overlap.length ? 'consistent' : unreachable.length === direct.length ? 'no-reference' : 'inconsistent',
directRequirementIds: direct,
derivedRequirementIds: [...derived].sort(byNumber),
overlapRequirementIds: overlap,
unreachableRequirementIds: unreachable,
reverseCandidates: [...reverse.entries()].sort(([left], [right]) => byNumber(left, right))
.map(([isoControlId, ids]) => ({ isoControlId, requirementIds: [...ids].sort(byNumber) }))
};
}
/**
* The canonical text a `Mark reviewed` fingerprint is hashed from: the compared accepted sets and the BSI mapping
* version. A changed decision or mapping version yields a new fingerprint, so the rule is listed again.
*/
export function bsiConsistencyFingerprintSource(acceptedIsoControlIds: readonly string[], acceptedBsiRequirementIds: readonly string[],
crosswalkVersion: string): string {
return JSON.stringify({
crosswalkVersion,
iso: [...new Set(acceptedIsoControlIds)].sort(byNumber),
bsi: [...new Set(acceptedBsiRequirementIds)].sort(byNumber)
});
}
/** A person kept the ISO <-> BSI result of a rule as it is, for one fingerprint. Global per rule like decisions. */
export interface RegulatoryConsistencyReview {
version: 1;
id: string;
ruleKey: string;
fingerprint: string;
comment?: string;
createdAt: string;
}
export function regulatoryConsistencyReviewValidationErrors(review: RegulatoryConsistencyReview): string[] {
const errors: string[] = [];
if (!review.id?.trim() || !review.ruleKey?.trim() || review.ruleKey.length > 200) errors.push('Consistency review identity is incomplete.');
if (!/^[a-f0-9]{64}$/.test(review.fingerprint)) errors.push('Consistency review fingerprint must be SHA-256.');
if (review.comment !== undefined && (review.comment.length > 1_000 || /<\/?[a-z][^>]*>/i.test(review.comment))) {
errors.push('Consistency review comment must be plain text with at most 1000 characters.');
}
return errors;
}
/** One rule of the Compliance tab review list, named by a Result DB row of the timeslot; never by its rule key. */
export interface RegulatoryConsistencyItem {
resultProjectionId: string;
title: string;
sourceTool: string;
sourceRuleId?: string;
/** Deviations of the timeslot with this rule, counted like Work. */
findingCount: number;
state: BsiConsistencyState;
/** A person kept exactly this result with `Mark reviewed`. */
reviewed: boolean;
iso: Array<{ controlId: string; title?: string }>;
direct: Array<{ requirementId: string; title?: string; practice?: string; derived: boolean; reachable: boolean }>;
derivedCount: number;
/** `decidable` when the latest ISO mapping of the rule retrieved the control, so it can be accepted here. */
reverse: Array<{ controlId: string; title?: string; requirementIds: string[]; decidable: boolean }>;
}
export interface RegulatoryConsistencyResponse {
version: 1;
projectId: string;
scope: { assessmentIds: string[]; scanRunIds: string[] };
crosswalkVersion: string;
catalogVersion: string;
/** Rules of the timeslot's deviations by state; `notCompared` has an accepted ISO control but no accepted BSI requirement. */
counts: { consistent: number; inconsistent: number; noReference: number; notCompared: number; reviewed: number };
items: RegulatoryConsistencyItem[];
}
export interface RegulatoryConsistencyReviewCreateRequest {
resultProjectionId: string;
comment?: string;
}

View File

@ -1,5 +1,6 @@
import type { AssessorRepository } from '../persistence/assessor-repository';
import type { RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMappingRun } from './regulatory-mapping';
import type { RegulatoryFramework, RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMappingRun } from './regulatory-mapping';
import type { RegulatoryConsistencyReview } from './bsi-consistency';
import type { AiOverviewRun } from './ai-overview';
/**
@ -26,6 +27,8 @@ export interface ComplianceProjectExport {
/** Mappings and decisions are global and keyed by scanner rule, not by project. */
regulatoryMappingRuns: RegulatoryMappingRun[];
regulatoryMappingDecisions: RegulatoryMappingDecision[];
/** ISO <-> BSI consistency reviews, global per rule like decisions. */
regulatoryConsistencyReviews: RegulatoryConsistencyReview[];
/** Succeeded AI overview runs of the project (table of migration 025, owned by the engine). */
aiOverviewRuns: AiOverviewRun[];
}
@ -45,6 +48,9 @@ export interface ComplianceStore {
updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun>;
listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise<RegulatoryMappingBatchRun[]>;
createRegulatoryMappingDecision(decision: RegulatoryMappingDecision, expectedLatestDecisionId?: string): Promise<RegulatoryMappingDecision>;
listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]>;
/** The decisions of one rule in one framework, ISO by default. */
listRegulatoryMappingDecisions(ruleKey: string, framework?: RegulatoryFramework): Promise<RegulatoryMappingDecision[]>;
createRegulatoryConsistencyReview(review: RegulatoryConsistencyReview): Promise<RegulatoryConsistencyReview>;
listRegulatoryConsistencyReviews(): Promise<RegulatoryConsistencyReview[]>;
exportProject(projectId: string): Promise<ComplianceProjectExport>;
}

View File

@ -4,11 +4,14 @@ import {
regulatoryMappingBatchRunValidationErrors,
regulatoryMappingDecisionValidationErrors,
regulatoryMappingRunValidationErrors,
mappingFramework,
type RegulatoryFramework,
type RegulatoryMappingBatchRun,
type RegulatoryMappingDecision,
type RegulatoryMappingRun
} from './regulatory-mapping';
import { aiOverviewRunValidationErrors, assertAiOverviewTransition, type AiOverviewRun } from './ai-overview';
import { regulatoryConsistencyReviewValidationErrors, type RegulatoryConsistencyReview } from './bsi-consistency';
function clone<T>(value: T): T {
return structuredClone(value);
@ -23,6 +26,7 @@ export class LocalComplianceStore implements ComplianceStore {
private batches: RegulatoryMappingBatchRun[] = [];
private decisions: RegulatoryMappingDecision[] = [];
private aiOverviewRuns: AiOverviewRun[] = [];
private consistencyReviews: RegulatoryConsistencyReview[] = [];
async getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null> {
const run = this.runs.find(item => item.id === id);
@ -90,10 +94,11 @@ export class LocalComplianceStore implements ComplianceStore {
const errors = regulatoryMappingDecisionValidationErrors(decision);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const run = this.runs.find(item => item.id === decision.mappingId);
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded' || mappingFramework(run) !== mappingFramework(decision)) {
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
}
const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId).sort(newestFirst)[0];
const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId
&& mappingFramework(item) === mappingFramework(decision)).sort(newestFirst)[0];
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
// previous decisions leads back to the run that grounded it.
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.');
@ -103,8 +108,19 @@ export class LocalComplianceStore implements ComplianceStore {
return clone(stored);
}
async listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]> {
return this.decisions.filter(item => item.ruleKey === ruleKey).map(clone).sort(newestFirst);
async listRegulatoryMappingDecisions(ruleKey: string, framework: RegulatoryFramework = 'iso27001'): Promise<RegulatoryMappingDecision[]> {
return this.decisions.filter(item => item.ruleKey === ruleKey && mappingFramework(item) === framework).map(clone).sort(newestFirst);
}
async createRegulatoryConsistencyReview(review: RegulatoryConsistencyReview): Promise<RegulatoryConsistencyReview> {
const errors = regulatoryConsistencyReviewValidationErrors(review);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
this.consistencyReviews.push(clone(review));
return clone(review);
}
async listRegulatoryConsistencyReviews(): Promise<RegulatoryConsistencyReview[]> {
return this.consistencyReviews.map(clone).sort(newestFirst);
}
async createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
@ -147,6 +163,7 @@ export class LocalComplianceStore implements ComplianceStore {
regulatoryMappingBatchRuns: this.batches.filter(item => item.projectId === projectId).map(clone),
regulatoryMappingRuns: this.runs.filter(item => item.status === 'succeeded').map(clone),
regulatoryMappingDecisions: this.decisions.map(clone),
regulatoryConsistencyReviews: this.consistencyReviews.map(clone),
aiOverviewRuns: this.aiOverviewRuns.filter(item => item.projectId === projectId && item.status === 'succeeded')
.map(clone).sort(newestFirst)
};

View File

@ -1,15 +1,41 @@
import type { AiOverviewTokenUsage } from './ai-overview';
import type { BsiFootprint, BsiOverview } from './bsi-grundschutz';
import type { BsiConsistencyState } from './bsi-consistency';
import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag';
import type { ResultDbFilters } from '../domain/result-db-query';
import type { UnifiedResultProjection } from '../domain/unified-result';
export const REGULATORY_MAPPING_PROMPT_VERSION = '1.2.0';
// 1.3.0 (user decision 2026-10-09): each ISO candidate carries its full ISO/IEC 27002 control text, not only the matched passages.
export const REGULATORY_MAPPING_PROMPT_VERSION = '1.3.0';
export const REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION = 1 as const;
export const REGULATORY_RETRIEVAL_VERSION = 'dense-control-grouping-1.1.0';
// Hybrid search (user decision 2026-10-09): dense English and German queries plus German keywords, fused by rank.
export const REGULATORY_RETRIEVAL_VERSION = 'hybrid-rrf-de-1.0.0';
/** Version of the GPO comparison context in the query; only GPO rules are regenerated when it changes. */
export const REGULATORY_GPO_QUERY_VERSION = 'gpo-baseline-context-1.1.0';
/**
* Phase 13F, BSI stage 2 (user decision 2026-10-09): a rule is mapped to ISO controls and, separately, directly
* to BSI Kernel requirements. ISO objects carry no framework field, so every earlier object stays unchanged.
*/
export type RegulatoryFramework = 'iso27001' | 'bsi-kernel';
export const BSI_MAPPING_PROMPT_VERSION = 'bsi-1.0.0';
export const BSI_RETRIEVAL_VERSION = 'bsi-hybrid-rrf-de-1.0.0';
export const BSI_CORPUS_ID = 'bsi-grundschutz-kernel';
export const BSI_SOURCE_STANDARD = 'BSI Grundschutz++';
const BSI_REQUIREMENT_ID = /^[A-Z]{2,5}(?:\.\d+)+$/;
// Since prompt 1.3.0 an ISO candidate carries every passage of its control text; runs before keep at most three.
const MAXIMUM_CANDIDATE_EVIDENCE = 24;
const MAXIMUM_CANDIDATES: Readonly<Record<RegulatoryFramework, number>> = { iso27001: 8, 'bsi-kernel': 16 };
export function mappingFramework(item: { framework?: RegulatoryFramework }): RegulatoryFramework {
return item.framework ?? 'iso27001';
}
/** The framework field an object carries: none for ISO, so ISO objects keep their earlier shape. */
export function frameworkField(framework: RegulatoryFramework): { framework?: 'bsi-kernel' } {
return framework === 'bsi-kernel' ? { framework } : {};
}
export type RegulatoryMappingRunStatus = 'queued' | 'running' | 'succeeded' | 'failed';
export type RegulatoryMappingRelationship = 'primary' | 'supporting';
export type RegulatoryMappingConfidence = 'low' | 'medium' | 'high';
@ -40,6 +66,8 @@ export interface RegulatoryMappingBatchCounts {
export interface RegulatoryMappingBatchRun extends RegulatoryMappingBatchScope {
version: 1;
id: string;
/** Absent for an ISO batch. */
framework?: 'bsi-kernel';
projectId: string;
scopeKey: string;
filterHash: string;
@ -67,16 +95,18 @@ export interface RegulatoryMappingBatchResponse {
export interface RegulatoryMappingBatchCreateRequest extends RegulatoryMappingBatchScope {
idempotencyKey: string;
framework?: RegulatoryFramework;
}
export interface RegulatoryMappingEvidence {
chunkId: string;
textSha256: string;
sourceSha256: string;
sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002';
sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002' | typeof BSI_SOURCE_STANDARD;
sectionKind: RegulatorySectionKind;
pageStart: number;
pageEnd: number;
/** ISO evidence only: the BSI catalog is one structured file without pages. */
pageStart?: number;
pageEnd?: number;
// Full ISO clause text. Required in the persisted run (the model reads it to reason),
// but omitted from the API response projection so the copyrighted text never reaches the browser.
text?: string;
@ -114,6 +144,8 @@ export interface RegulatoryMappingResult {
export interface RegulatoryMappingRun {
version: 1;
id: string;
/** Absent for an ISO mapping. */
framework?: 'bsi-kernel';
ruleKey: string;
sourceTool: string;
sourceRuleId?: string;
@ -150,6 +182,8 @@ export interface RegulatoryMappingRun {
export interface RegulatoryMappingDecision {
version: 1;
id: string;
/** Absent for an ISO decision. */
framework?: 'bsi-kernel';
ruleKey: string;
sourceTool: string;
sourceRuleId?: string;
@ -209,6 +243,8 @@ export const MAPPING_HISTORY_LIMIT = 50;
export interface RegulatoryMappingHistoryEntry {
id: string;
kind: 'batch' | 'single';
/** ISO controls or direct BSI requirements (user decision 2026-10-09); absent means ISO. */
framework?: RegulatoryFramework;
/** Where the mapping was started, relative to the shown timeslot; `unknown` for a single mapping without a recorded origin. */
scope: 'timeslot' | 'other-timeslot' | 'other-client' | 'unknown';
/** Automatic only for batches the auto-mapping watcher started. */
@ -262,6 +298,8 @@ export interface RegulatoryMappingResponse {
version: 1;
projectId: string;
resultProjectionId: string;
/** Absent means ISO. */
framework?: RegulatoryFramework;
generationEnabled: boolean;
state: 'empty' | RegulatoryMappingRunStatus | 'unsupported';
disabledReason?: string;
@ -275,6 +313,8 @@ export interface RegulatoryMappingResponse {
earlierControls?: RegulatoryEarlierControl[];
/** BSI Kernel requirements derived from this rule's accepted controls; absent when none is derived. */
bsi?: BsiOverview;
/** ISO <-> BSI consistency of this rule; only on a BSI response and only once a BSI requirement is accepted. */
consistency?: { state: BsiConsistencyState; reviewed: boolean };
}
export interface RegulatoryEarlierControl {
@ -285,12 +325,14 @@ export interface RegulatoryEarlierControl {
export interface RegulatoryMappingCreateRequest {
resultProjectionId: string;
framework?: RegulatoryFramework;
/** The timeslot the Finding Reader shows; the run keeps it only as hashes, for the mapping history scope. */
origin?: { assessmentIds: string[]; scanRunIds: string[] };
}
export interface RegulatoryMappingDecisionCreateRequest {
resultProjectionId: string;
framework?: RegulatoryFramework;
controlId: string;
decision: RegulatoryMappingDecisionValue;
comment?: string;
@ -365,18 +407,25 @@ export function regulatoryMappingRunValidationErrors(run: RegulatoryMappingRun):
if (!/^[a-f0-9]{64}$/.test(run.queryHash)) errors.push('Regulatory mapping queryHash must be SHA-256.');
const candidatesRequired = run.status === 'running' || run.status === 'succeeded';
if ((!run.sourceHashes.length && candidatesRequired) || run.sourceHashes.some(value => !/^[a-f0-9]{64}$/.test(value))) errors.push('Regulatory mapping sourceHashes must contain SHA-256 digests.');
if (!Array.isArray(run.candidates) || (candidatesRequired && run.candidates.length < 1) || run.candidates.length > 8) errors.push('Regulatory mapping candidates must contain one to eight controls when processing has started.');
if (run.framework !== undefined && run.framework !== 'bsi-kernel') errors.push('Regulatory mapping framework is invalid.');
const bsi = mappingFramework(run) === 'bsi-kernel';
const maximum = MAXIMUM_CANDIDATES[mappingFramework(run)];
if (!Array.isArray(run.candidates) || (candidatesRequired && run.candidates.length < 1) || run.candidates.length > maximum) errors.push(`Regulatory mapping candidates must contain one to ${maximum} controls when processing has started.`);
else {
const controls = new Set<string>();
run.candidates.forEach(candidate => {
if (!/^[5-8]\.\d{1,2}$/.test(candidate.controlId) || candidate.annexAControlId !== `A.${candidate.controlId}`) errors.push('Regulatory mapping candidate control identity is invalid.');
const identity = bsi ? BSI_REQUIREMENT_ID.test(candidate.controlId) && candidate.annexAControlId === candidate.controlId
: /^[5-8]\.\d{1,2}$/.test(candidate.controlId) && candidate.annexAControlId === `A.${candidate.controlId}`;
if (!identity) errors.push('Regulatory mapping candidate control identity is invalid.');
if (controls.has(candidate.controlId)) errors.push(`Regulatory mapping candidate ${candidate.controlId} is duplicated.`);
controls.add(candidate.controlId);
if (!candidate.title.trim() || candidate.title.length > 500 || !Number.isFinite(candidate.score)) errors.push(`Regulatory mapping candidate ${candidate.controlId} metadata is invalid.`);
if (!candidate.evidence.length || candidate.evidence.length > 3) errors.push(`Regulatory mapping candidate ${candidate.controlId} must contain one to three evidence chunks.`);
if (!candidate.evidence.length || candidate.evidence.length > MAXIMUM_CANDIDATE_EVIDENCE) errors.push(`Regulatory mapping candidate ${candidate.controlId} must contain one to ${MAXIMUM_CANDIDATE_EVIDENCE} evidence chunks.`);
candidate.evidence.forEach(evidence => {
if (!evidence.chunkId.trim() || !/^[a-f0-9]{64}$/.test(evidence.textSha256) || !/^[a-f0-9]{64}$/.test(evidence.sourceSha256)
|| !Number.isInteger(evidence.pageStart) || !Number.isInteger(evidence.pageEnd) || evidence.pageStart < 1 || evidence.pageEnd < evidence.pageStart
|| (bsi ? evidence.sourceStandard !== BSI_SOURCE_STANDARD || evidence.pageStart !== undefined || evidence.pageEnd !== undefined
: evidence.sourceStandard === BSI_SOURCE_STANDARD || !Number.isInteger(evidence.pageStart) || !Number.isInteger(evidence.pageEnd)
|| evidence.pageStart! < 1 || evidence.pageEnd! < evidence.pageStart!)
|| typeof evidence.text !== 'string' || !evidence.text.trim() || evidence.text.length > 20_000) errors.push(`Regulatory mapping candidate ${candidate.controlId} evidence is invalid.`);
});
});
@ -446,6 +495,7 @@ export function regulatoryMappingBatchRunValidationErrors(run: RegulatoryMapping
if (!/^[a-f0-9]{64}$/.test(run.filterHash)) errors.push('Regulatory mapping batch filterHash must be SHA-256.');
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(run.idempotencyKey)) errors.push('Regulatory mapping batch idempotencyKey is invalid.');
if (!['queued', 'running', 'completed', 'failed'].includes(run.status)) errors.push('Regulatory mapping batch status is invalid.');
if (run.framework !== undefined && run.framework !== 'bsi-kernel') errors.push('Regulatory mapping batch framework is invalid.');
const normalized = normalizeRegulatoryMappingBatchScope(run);
if (!normalized.assessmentIds.length && !normalized.scanRunIds.length) errors.push('Regulatory mapping batch requires a selected timeslot.');
const counts = Object.values(run.counts);
@ -472,6 +522,7 @@ export function regulatoryMappingDecisionValidationErrors(decision: RegulatoryMa
}
if (decision.sourceRuleId !== undefined && (!decision.sourceRuleId.trim() || decision.sourceRuleId.length > 200)) errors.push('Regulatory mapping decision sourceRuleId is invalid.');
if (!['accepted', 'rejected'].includes(decision.decision)) errors.push('Regulatory mapping decision is invalid.');
if (decision.framework !== undefined && decision.framework !== 'bsi-kernel') errors.push('Regulatory mapping decision framework is invalid.');
if (decision.comment !== undefined && (decision.comment.length > 1_000 || /<\/?[a-z][^>]*>/i.test(decision.comment))) errors.push('Regulatory mapping decision comment must be plain text with at most 1000 characters.');
return [...new Set(errors)];
}

View File

@ -17,7 +17,19 @@ const publicTools: Record<string, string> = {
gpos: 'Microsoft Group Policy',
maester: 'Maester',
pingcastle: 'PingCastle',
'purple-knight': 'Semperis Purple Knight'
'purple-knight': 'Semperis Purple Knight',
// The Result DB names Purple Knight by its assessed scope (result-db-disclosure).
'purple-knight-ad': 'Semperis Purple Knight for Active Directory',
'purple-knight-entra': 'Semperis Purple Knight for Entra ID',
'purple-knight-combined': 'Semperis Purple Knight for Active Directory and Entra ID'
};
/** Public indicator IDs of tools without a bundled rule catalog, recognized by their published form only. */
const publicRuleIdPatterns: Record<string, RegExp> = {
'purple-knight': /^SI\d{6}$/,
'purple-knight-ad': /^SI\d{6}$/,
'purple-knight-entra': /^SI\d{6}$/,
'purple-knight-combined': /^SI\d{6}$/
};
const publicRuleIds: Record<string, readonly string[]> = {
@ -157,6 +169,7 @@ function policyContext(item: UnifiedResultProjection, title: string): string[] {
function recognizedRuleId(toolId: string, value: string | undefined): string | undefined {
const candidate = value?.trim();
if (!candidate) return undefined;
if (publicRuleIdPatterns[toolId]?.test(candidate)) return candidate;
return publicRuleIds[toolId]?.find(id => id.toLowerCase() === candidate.toLowerCase());
}

View File

@ -112,6 +112,18 @@ export interface RegulatoryFindingQuery {
publicTool?: string;
publicRuleId?: string;
publicTopics: string[];
/** The German restatement the hybrid search used, recorded on the run for provenance; absent before it existed or when it failed. */
expansion?: RegulatoryQueryExpansion;
}
/** A German restatement of the public query by the model, for the German norm texts (user decision 2026-10-09). */
export interface RegulatoryQueryExpansion {
language: 'de';
version: string;
query: string;
terms: string[];
/** The ranked lists fused for this run, for example `dense-en`, `dense-de`, `keyword-de`. */
searches: string[];
}
export interface RegulatoryReranker {

View File

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { BsiCatalogState } from '../bsi-catalog-management';
import type { BsiCatalogState, BsiSearchIndexStatus } from '../bsi-catalog-management';
import { markRegulatoryDecisionSaved } from './decision-revision';
const api = new AssessorApiClient();
const INDEX_POLL_MS = 2_000;
/** Data Hub BSI catalog tile, like the MITRE matrix tile; a switched-off Compliance Engine renders and requests nothing. */
export function BsiCatalogLibrary() {
@ -12,12 +13,27 @@ export function BsiCatalogLibrary() {
}
const day = (version: string) => version.slice(0, 10);
const number = (value: number) => value.toLocaleString('en-US');
function indexLabel(index: BsiSearchIndexStatus): string {
switch (index.state) {
case 'built': return `Built · ${number(index.indexedChunks)} chunks for ${number(index.requirementCount)} requirements`;
case 'building': return index.progress ? `Building · ${number(index.progress.completed)} of ${number(index.progress.total)} chunks embedded` : 'Building …';
case 'outdated': return 'Outdated · built for another catalog state';
case 'not-built': return 'Not built · the first BSI mapping builds it otherwise';
case 'failed': return 'Build failed';
case 'unavailable': return 'Unavailable';
}
}
function BsiCatalogLibraryPanel() {
const [status, setStatus] = useState<BsiCatalogState | null>(null);
const [busy, setBusy] = useState<'refresh' | 'reset' | null>(null);
const [index, setIndex] = useState<BsiSearchIndexStatus | null>(null);
const [busy, setBusy] = useState<'refresh' | 'reset' | 'index' | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// The search index status is optional: an older backend or a failure leaves only the catalog part.
const loadIndex = useCallback(async () => { try { setIndex(await api.getBsiSearchIndex()); } catch { setIndex(null); } }, []);
useEffect(() => {
let active = true;
// Any failure stays inside this tile; Data Hub keeps working.
@ -25,8 +41,15 @@ function BsiCatalogLibraryPanel() {
try { const value = await api.getBsiCatalog(); if (active) setStatus(value); }
catch (reason) { if (active) setError(reason instanceof Error ? reason.message : 'The BSI catalog status could not be loaded.'); }
})();
void loadIndex();
return () => { active = false; };
}, []);
}, [loadIndex]);
// While the index builds on the server, its progress is shown without a reload.
useEffect(() => {
if (index?.state !== 'building') return;
const timer = window.setTimeout(() => void loadIndex(), INDEX_POLL_MS);
return () => window.clearTimeout(timer);
}, [index, loadIndex]);
const run = async (action: 'refresh' | 'reset') => {
setBusy(action); setFeedback(null); setError(null);
@ -44,9 +67,21 @@ function BsiCatalogLibraryPanel() {
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The BSI catalog could not be updated.');
} finally { setBusy(null); }
} finally {
setBusy(null);
// Another catalog state needs its own search index.
void loadIndex();
}
};
const buildIndex = async () => {
setBusy('index'); setFeedback(null); setError(null);
try { setIndex(await api.buildBsiSearchIndex(index?.state === 'built')); }
catch (reason) { setError(reason instanceof Error ? reason.message : 'The BSI search index could not be built.'); }
finally { setBusy(null); }
};
const building = index?.state === 'building';
return <section className="datahub-rack-module datahub-reference-module bsi-catalog-library" data-bsi-catalog>
<header className="datahub-rack-header">
<span className="datahub-grip" aria-hidden="true">⠿</span>
@ -57,16 +92,24 @@ function BsiCatalogLibraryPanel() {
: 'Loading…'}</span>
</header>
<div className="datahub-rack-body">
{index && <div className="iso-corpus-summary" role="status" data-bsi-search-index={index.state}>
<span><strong>Search index</strong> {indexLabel(index)}</span>
</div>}
<div className="datahub-rack-actions">
<button className="btn datahub-maintenance-action" type="button" disabled={busy !== null || !status} onClick={() => void run('refresh')}>
<button className="btn datahub-maintenance-action" type="button" disabled={busy !== null || building || !status} onClick={() => void run('refresh')}>
{busy === 'refresh' ? 'Checking BSI…' : 'Update from BSI'}
</button>
{status?.origin === 'bsi' && <button className="btn" type="button" disabled={busy !== null} onClick={() => void run('reset')}>
{status?.origin === 'bsi' && <button className="btn" type="button" disabled={busy !== null || building} onClick={() => void run('reset')}>
{busy === 'reset' ? 'Restoring…' : 'Use bundled catalog'}
</button>}
{index && index.state !== 'unavailable' && <button className="btn" type="button" disabled={busy !== null || building} onClick={() => void buildIndex()}>
{building ? 'Building search index…' : index.state === 'built' ? 'Rebuild search index' : 'Build search index'}
</button>}
</div>
</div>
{feedback && <div className="tenant-save-feedback success" role="status"><span aria-hidden="true">✓</span><strong>{feedback}</strong></div>}
{index?.state === 'failed' && index.error && <div className="datahub-alert" role="alert"><span>{index.error}</span></div>}
{index?.state === 'unavailable' && index.disabledReason && <div className="datahub-alert" role="alert"><span>{index.disabledReason}</span></div>}
{error && <div className="datahub-alert" role="alert"><span>{error}</span></div>}
</section>;
}

View File

@ -9,6 +9,7 @@ import { useRegulatoryDecisionRevision } from './decision-revision';
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
import type { ComplianceMappingState } from './compliance-work-facets';
import { MappingReviewTile } from './MappingReviewTile';
import './compliance.css';
const assessorApi = new AssessorApiClient();
@ -181,6 +182,8 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
</section>
<BsiTile state={state} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
{/* User decision 2026-10-09: ISO <-> BSI consistency review beside the framework tiles. */}
{projectId && <MappingReviewTile projectId={projectId} assessmentIds={assessmentIds} scanRunIds={scanRunIds} backendStatus={backendStatus}/>}
</div>
</section>
</div>;
@ -219,27 +222,30 @@ export function resetClientComplianceCacheForTests(): void { overviewCache.clear
const MAPPING_POLL_MS = 5_000;
const triggerLabel = (entry: RegulatoryMappingHistoryEntry) =>
entry.kind === 'single' ? 'Single finding' : entry.trigger === 'automatic' ? 'Automatic' : 'Manual';
const FRAMEWORK_LABEL = { iso27001: 'ISO 27001/27002', 'bsi-kernel': 'BSI Grundschutz++' } as const;
const SCOPE_LABEL = { timeslot: 'This timeslot', 'other-timeslot': 'Other timeslot', 'other-client': 'Other client', unknown: 'Unknown' } as const;
const STATUS_LABEL = { queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' } as const;
const dateTime = (value: string) => new Date(value).toLocaleString('en-GB',
{ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
/**
* The latest ISO mapping of any client and what started it; mappings are global per rule. The window lists
* the latest batches and single finding mappings with their scope and rule counts.
* The latest mapping of any client and what started it; mappings are global per rule. The window lists the latest
* ISO and direct BSI batches and single finding mappings with their framework, scope and rule counts (user decision
* 2026-10-09); the button names BSI, an ISO mapping keeps its earlier label.
*/
function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] }) {
const latest = history[0];
if (!latest) return <span className="client-compliance-last-mapping is-empty">No mapping yet</span>;
const label = `Last mapping: ${dateTime(latest.createdAt)} · ${triggerLabel(latest)}`
const label = `Last mapping: ${dateTime(latest.createdAt)}${latest.framework === 'bsi-kernel' ? ' · BSI' : ''} · ${triggerLabel(latest)}`
+ (latest.scope === 'timeslot' ? '' : ` · ${SCOPE_LABEL[latest.scope]}`)
+ (latest.status === 'completed' ? '' : ` · ${STATUS_LABEL[latest.status]}`);
return <EnvironmentResultsButton title="ISO mapping history" trigger={label} triggerClassName="btn client-compliance-last-mapping">
return <EnvironmentResultsButton title="Mapping history" trigger={label} triggerClassName="btn client-compliance-last-mapping">
<div className="client-env-evidence-scroll">
<table className="client-information-table client-compliance-history">
<thead><tr><th>Started</th><th>Trigger</th><th>Scope</th><th>Status</th><th>Findings</th><th>Sent to model</th><th>Reused</th><th>Succeeded</th><th>Failed</th></tr></thead>
<thead><tr><th>Started</th><th>Framework</th><th>Trigger</th><th>Scope</th><th>Status</th><th>Findings</th><th>Sent to model</th><th>Reused</th><th>Succeeded</th><th>Failed</th></tr></thead>
<tbody>{history.map(entry => <tr key={entry.id}>
<td>{dateTime(entry.createdAt)}</td>
<td>{FRAMEWORK_LABEL[entry.framework ?? 'iso27001']}</td>
<td>{triggerLabel(entry)}</td>
<td>{SCOPE_LABEL[entry.scope]}</td>
<td>{STATUS_LABEL[entry.status]}</td>

View File

@ -1,25 +1,51 @@
import { TriangleIcon } from '../../components/TriangleIcon';
import type { BackendStatus } from '../../app/app-state';
import type { BsiOverview } from '../bsi-grundschutz';
import type { BsiConsistencyState } from '../bsi-consistency';
import { RegulatoryMappingDialog } from './RegulatoryMappingPanel';
import { useRegulatoryMapping } from './use-regulatory-mapping';
import './compliance.css';
const byNumber = (left: string, right: string) => left.localeCompare(right, 'en', { numeric: true });
export const CONSISTENCY_LABELS: Readonly<Record<BsiConsistencyState, string>> = {
consistent: 'Consistent with ISO',
inconsistent: 'Inconsistent with ISO',
'no-reference': 'No ISO reference'
};
/**
* Finding Reader section of the BSI Grundschutz++ requirements derived from this finding's accepted ISO
* controls through the BSI mapping (Phase 13F, stage 1). Derived only; it is no decision of its own.
* User decision 2026-10-08: it starts collapsed like `Context`, groups the requirements under their ISO
* control as one-line rows and shows a statement only when its requirement is opened.
* Finding Reader section of the BSI Grundschutz++ requirements of this finding. Stage 1 (user decision
* 2026-10-08): requirements derived from the accepted ISO controls through the BSI mapping; derived only, never a
* decision. It starts collapsed like `Context`, groups them under their ISO control as one-line rows and shows a
* statement only when its requirement is opened. Stage 2 (user decision 2026-10-09): the direct BSI mapping of the
* same rule, its accepted requirements and their consistency with the ISO side; a disagreement is only shown.
*/
export function FindingBsiRequirements({ bsi }: { bsi?: BsiOverview }) {
if (!bsi?.requirements.length) return null;
const isoControls = [...new Set(bsi.requirements.flatMap(requirement => requirement.via.map(via => via.isoControlId)))].sort(byNumber);
const count = bsi.requirements.length;
return <details className="finding-context finding-bsi-requirements" data-bsi-requirements>
export function FindingBsiRequirements({ bsi, projectId, resultProjectionId, backendStatus }: {
bsi?: BsiOverview;
projectId: string;
/** Result DB row of the record; empty when the record has none, then only the derived requirements are shown. */
resultProjectionId: string;
backendStatus: BackendStatus;
}) {
const direct = useRegulatoryMapping(projectId, resultProjectionId, backendStatus, 'bsi-kernel');
const derived = bsi?.requirements ?? [];
// Only once the backend delivered the BSI mapping state of a mappable finding: when it can map or has mapped.
const showDirect = direct.available && Boolean(resultProjectionId) && Boolean(direct.response)
&& (direct.response!.generationEnabled || Boolean(direct.response!.run) || direct.acceptedCount > 0);
if (!derived.length && !showDirect) return null;
const isoControls = [...new Set(derived.flatMap(requirement => requirement.via.map(via => via.isoControlId)))].sort(byNumber);
const derivedIds = new Set(derived.map(requirement => requirement.requirementId));
const consistency = direct.response?.consistency;
const meta = [derived.length ? `${derived.length} ${derived.length === 1 ? 'requirement' : 'requirements'} derived from the accepted ISO controls` : '', direct.acceptedCount ? `${direct.acceptedCount} mapped directly` : '',
consistency ? CONSISTENCY_LABELS[consistency.state] + (consistency.reviewed ? ' (reviewed)' : '') : ''].filter(Boolean).join(' · ');
// Keyed by finding, so every finding starts with the section collapsed.
return <details key={`${projectId}\u0000${resultProjectionId}`} className="finding-context finding-bsi-requirements" data-bsi-requirements>
<summary className="finding-context__header"><TriangleIcon direction="right"/><h4>BSI requirements</h4>
<span className="finding-context__meta">{count} {count === 1 ? 'requirement' : 'requirements'} derived from the accepted ISO controls</span></summary>
<span className="finding-context__meta">{meta || 'Not mapped yet'}</span></summary>
<div className="finding-context__content">
{isoControls.map(isoControlId => {
const reached = bsi.requirements.filter(requirement => requirement.via.some(via => via.isoControlId === isoControlId));
const reached = derived.filter(requirement => requirement.via.some(via => via.isoControlId === isoControlId));
// One BSI mapping entry per ISO control, so its relationship holds for the whole group.
const relationship = reached[0].via.find(via => via.isoControlId === isoControlId)!.relationship;
return <div key={isoControlId} className="finding-bsi-requirements__group" data-iso-control={isoControlId}>
@ -32,9 +58,26 @@ export function FindingBsiRequirements({ bsi }: { bsi?: BsiOverview }) {
</li>)}</ul>
</div>;
})}
<p className="finding-bsi-requirements__source">
{showDirect && <div className="finding-bsi-requirements__group finding-bsi-requirements__direct" data-bsi-direct>
<div className="finding-bsi-requirements__direct-header">
<p className="finding-bsi-requirements__route">Mapped directly{consistency ? ` · ${CONSISTENCY_LABELS[consistency.state]}${consistency.reviewed ? ' (reviewed)' : ''}` : ''}</p>
<button className="btn" type="button" aria-expanded={direct.open} disabled={!direct.open && Boolean(direct.disabledReason)}
title={direct.disabledReason} onClick={() => direct.setOpen(!direct.open)}>
Map BSI requirements{direct.acceptedCount ? ` (${direct.acceptedCount})` : ''}
</button>
</div>
{direct.acceptedControls.length > 0 && <ul>{direct.acceptedControls.map(requirement => <li key={requirement.controlId} data-direct-requirement={requirement.controlId}>
<div className="finding-bsi-requirements__item"><strong>{requirement.controlId}</strong><span>{requirement.title}</span>
{derivedIds.has(requirement.controlId) && <span className="finding-context__meta">also derived</span>}</div>
</li>)}</ul>}
</div>}
{bsi && <p className="finding-bsi-requirements__source">
Via BSI mapping {bsi.crosswalkVersion} · catalog {bsi.catalogVersion.slice(0, 10)} · Source: BSI Stand-der-Technik-Bibliothek, CC BY-SA 4.0
</p>
</p>}
{!bsi && showDirect && <p className="finding-bsi-requirements__source">Source: BSI Stand-der-Technik-Bibliothek, CC BY-SA 4.0</p>}
</div>
{direct.open && <RegulatoryMappingDialog framework="bsi-kernel" response={direct.response} loading={direct.loading} error={direct.error}
onStart={() => void direct.start()} onDecision={(candidate, decision, comment) => void direct.decide(candidate, decision, comment)}
onClose={() => direct.setOpen(false)}/>}
</details>;
}

View File

@ -88,7 +88,7 @@ function IsoCorpusLibraryPanel() {
onChange={event => void toggleAutoMapping(event.target.checked)}/>
<span>Map new scans automatically</span>
</label>}
{status?.autoMapping && <p className="iso-corpus-note">After each upload, deviations of rules without an ISO mapping are mapped like <strong>Map all ISO controls</strong>. Rules mapped before are reused.</p>}
{status?.autoMapping && <p className="iso-corpus-note">After each upload, deviations of rules without an ISO mapping are mapped like <strong>Compliance mapping</strong> in the Result DB. Rules mapped before are reused.</p>}
<form className="iso-corpus-form" onSubmit={event => void submit(event)}>
<label><span>DIN EN ISO/IEC 27001:2024-01 PDF</span><input className="input" type="file" accept=".pdf,application/pdf" required disabled={submitting || running} onChange={event => setIso27001(event.target.files?.[0] ?? null)}/></label>
<label><span>DIN EN ISO/IEC 27002:2024-01 PDF</span><input className="input" type="file" accept=".pdf,application/pdf" required disabled={submitting || running} onChange={event => setIso27002(event.target.files?.[0] ?? null)}/></label>

View File

@ -0,0 +1,161 @@
import { useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { BackendStatus } from '../../app/app-state';
import { DashboardGlyph } from '../../features/dashboard/ClientTileParts';
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
import type { BsiConsistencyState, RegulatoryConsistencyItem, RegulatoryConsistencyResponse } from '../bsi-consistency';
import { useRegulatoryDecisionRevision } from './decision-revision';
import { CONSISTENCY_LABELS } from './FindingBsiRequirements';
import { useRegulatoryMapping } from './use-regulatory-mapping';
import './compliance.css';
const assessorApi = new AssessorApiClient();
export const MAPPING_REVIEW_TITLE = 'ISO ↔ BSI review';
const number = (value: number) => value.toLocaleString('en-US');
const FILTERS: Array<{ state: BsiConsistencyState; label: string }> = [
{ state: 'inconsistent', label: 'Inconsistent' }, { state: 'no-reference', label: 'No ISO reference' }, { state: 'consistent', label: 'Consistent' }];
type ReviewState = { status: 'loading' } | { status: 'error' } | { status: 'ready'; review: RegulatoryConsistencyResponse };
/**
* Client Info `Compliance` tab tile of the ISO <-> BSI consistency review (Phase 13F, BSI stage 2, user decision
* 2026-10-09): per rule of the timeslot's deviations, the directly accepted BSI requirements against the ones the BSI
* mapping derives from the accepted ISO controls. The Results window lists the rules for a person to review; a
* disagreement is only shown and changes no decision by itself.
*/
export function MappingReviewTile({ projectId, assessmentIds, scanRunIds, backendStatus }: {
projectId: string;
assessmentIds: readonly string[];
scanRunIds: readonly string[];
backendStatus: BackendStatus;
}) {
const revision = useRegulatoryDecisionRevision();
const [state, setState] = useState<ReviewState>({ status: 'loading' });
const [reload, setReload] = useState(0);
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
useEffect(() => {
let active = true;
// A refresh keeps the shown values until the new review arrives; only the first load shows em dashes.
setState(current => current.status === 'ready' && current.review.projectId === projectId ? current : { status: 'loading' });
assessorApi.getRegulatoryMappingConsistency(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
.then(review => { if (active) setState({ status: 'ready', review }); })
.catch(() => { if (active) setState(current => current.status === 'ready' ? current : { status: 'error' }); });
return () => { active = false; };
}, [scopeKey, revision, reload]); // eslint-disable-line react-hooks/exhaustive-deps
const ready = state.status === 'ready';
const counts = ready ? state.review.counts : undefined;
const value = (amount: number | undefined) => amount === undefined ? '—' : number(amount);
const compared = counts ? counts.consistent + counts.inconsistent + counts.noReference : undefined;
return <section className="client-env-group" data-framework="iso-bsi-review">
<header className="client-env-group-header">
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{MAPPING_REVIEW_TITLE}</span></h3>
{ready && <EnvironmentResultsButton title={MAPPING_REVIEW_TITLE}>
<ReviewList review={state.review} projectId={projectId} backendStatus={backendStatus} onChanged={() => setReload(current => current + 1)}/>
</EnvironmentResultsButton>}
</header>
{state.status === 'error' ? <p role="alert" className="iso-controls__error">The ISO ↔ BSI review could not be loaded.</p>
: <dl className="client-env-facts">
<div><dt>Compared rules</dt><dd>{value(compared)}</dd></div>
<div><dt>Inconsistent</dt><dd>{value(counts?.inconsistent)}</dd></div>
<div><dt>No ISO reference</dt><dd>{value(counts?.noReference)}</dd></div>
<div><dt>Consistent</dt><dd>{value(counts?.consistent)}</dd></div>
<div><dt>Not compared</dt><dd>{value(counts?.notCompared)}</dd></div>
<div><dt>Reviewed</dt><dd>{value(counts?.reviewed)}</dd></div>
</dl>}
</section>;
}
function ReviewList({ review, projectId, backendStatus, onChanged }: {
review: RegulatoryConsistencyResponse; projectId: string; backendStatus: BackendStatus; onChanged(): void;
}) {
const [filter, setFilter] = useState<BsiConsistencyState>('inconsistent');
const items = review.items.filter(item => item.state === filter);
return <div className="mapping-review">
<p className="mapping-review__intro">Each rule compares the BSI requirements accepted in its direct BSI mapping with the requirements the BSI
mapping {review.crosswalkVersion} derives from its accepted ISO controls. Agreement shows consistency, not correctness. Nothing here changes a
decision until you accept or reject one.</p>
<div className="client-info-tabs mapping-review__filters" role="group" aria-label="Consistency state">
{FILTERS.map(option => {
const amount = review.items.filter(item => item.state === option.state).length;
return <button key={option.state} type="button" aria-pressed={filter === option.state}
onClick={() => setFilter(option.state)}>{option.label} ({number(amount)})</button>;
})}
</div>
{review.counts.notCompared > 0 && <p className="mapping-review__note">{number(review.counts.notCompared)} {review.counts.notCompared === 1 ? 'rule has' : 'rules have'} an
accepted ISO control but no accepted BSI requirement yet. Start the BSI mapping batch in the Result DB to compare them.</p>}
{!items.length ? <p className="iso-controls__empty">No rule in this state.</p>
: <div className="mapping-review__items">{items.map(item => <ReviewItem key={item.resultProjectionId} item={item} projectId={projectId}
backendStatus={backendStatus} onChanged={onChanged}/>)}</div>}
<p className="bsi-requirements__source">BSI texts: BSI Stand-der-Technik-Bibliothek, catalog {review.catalogVersion.slice(0, 10)}, CC BY-SA 4.0.</p>
</div>;
}
function ReviewItem({ item, projectId, backendStatus, onChanged }: {
item: RegulatoryConsistencyItem; projectId: string; backendStatus: BackendStatus; onChanged(): void;
}) {
const [open, setOpen] = useState(false);
return <details className="mapping-review__item" data-consistency={item.state} open={open} onToggle={event => setOpen(event.currentTarget.open)}>
<summary>
<span className="mapping-review__state">{CONSISTENCY_LABELS[item.state]}{item.reviewed ? ' · reviewed' : ''}</span>
<span className="mapping-review__title">{item.title}</span>
<span className="mapping-review__meta">{item.sourceTool}{item.sourceRuleId ? ` · ${item.sourceRuleId}` : ''} · {number(item.findingCount)} {item.findingCount === 1 ? 'finding' : 'findings'}</span>
</summary>
{/* The decision hooks load only for an opened rule. */}
{open && <ReviewDetail item={item} projectId={projectId} backendStatus={backendStatus} onChanged={onChanged}/>}
</details>;
}
function ReviewDetail({ item, projectId, backendStatus, onChanged }: {
item: RegulatoryConsistencyItem; projectId: string; backendStatus: BackendStatus; onChanged(): void;
}) {
const iso = useRegulatoryMapping(projectId, item.resultProjectionId, backendStatus);
const bsi = useRegulatoryMapping(projectId, item.resultProjectionId, backendStatus, 'bsi-kernel');
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const busy = saving || iso.loading || bsi.loading;
const markReviewed = async () => {
setSaving(true);
try {
await assessorApi.markRegulatoryMappingConsistencyReviewed(projectId, { resultProjectionId: item.resultProjectionId, ...(comment.trim() ? { comment: comment.trim() } : {}) });
setError('');
onChanged();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'The review could not be saved.');
} finally { setSaving(false); }
};
// A saved decision refreshes the tab overview and this list through the shared decision revision.
return <div className="mapping-review__detail">
<div className="mapping-review__side" aria-label="Accepted ISO controls">
<h4>Accepted ISO controls · {number(item.derivedCount)} derived BSI {item.derivedCount === 1 ? 'requirement' : 'requirements'}</h4>
{item.iso.length ? <ul>{item.iso.map(control => <li key={control.controlId}>
<strong>A.{control.controlId}</strong><span>{control.title ?? ''}</span>
<button className="btn" type="button" disabled={busy || !iso.response} onClick={() => void iso.decide(control, 'rejected', 'Rejected in the ISO ↔ BSI review.')}>Reject</button>
</li>)}</ul> : <p>No accepted ISO control.</p>}
</div>
<div className="mapping-review__side" aria-label="Directly accepted BSI requirements">
<h4>Mapped directly to BSI</h4>
<ul>{item.direct.map(requirement => <li key={requirement.requirementId}>
<strong>{requirement.requirementId}</strong><span>{requirement.title ?? ''}</span>
<span className="mapping-review__meta">{requirement.derived ? 'also derived from ISO' : requirement.reachable ? 'not derived from the accepted ISO controls' : 'no ISO control maps here'}</span>
<button className="btn" type="button" disabled={busy || !bsi.response} onClick={() => void bsi.decide({ controlId: requirement.requirementId }, 'rejected', 'Rejected in the ISO ↔ BSI review.')}>Reject</button>
</li>)}</ul>
</div>
{item.reverse.length > 0 && <div className="mapping-review__side" aria-label="ISO controls suggested by the BSI mapping">
<h4>ISO controls the BSI mapping points to</h4>
<ul>{item.reverse.map(control => <li key={control.controlId}>
<strong>A.{control.controlId}</strong><span>{control.title ?? ''}</span>
<span className="mapping-review__meta">via {control.requirementIds.join(', ')}</span>
{control.decidable
? <button className="btn" type="button" disabled={busy || !iso.response} onClick={() => void iso.decide(control, 'accepted', 'Accepted in the ISO ↔ BSI review.')}>Accept</button>
: <span className="mapping-review__meta">not retrieved by the ISO mapping</span>}
</li>)}</ul>
</div>}
{(iso.error || bsi.error || error) && <p className="regulatory-mapping-error" role="alert">{error || iso.error || bsi.error}</p>}
{item.state !== 'consistent' && !item.reviewed && <div className="mapping-review__actions">
<label>Review comment (optional)<input value={comment} maxLength={1000} onChange={event => setComment(event.target.value)}/></label>
<button className="btn" type="button" disabled={busy} onClick={() => void markReviewed()}>Mark reviewed</button>
</div>}
</div>;
}

View File

@ -4,7 +4,7 @@ import { useEnginePreferences } from '../../app/engine-preferences';
import { useBackendWorkActivity } from './use-backend-work-activity';
import { statusPollApi } from './status-poll-client';
import { Dialog } from '../../components/Dialog/Dialog';
import type { RegulatoryMappingBatchResponse, RegulatoryMappingBatchScope } from '../regulatory-mapping';
import type { RegulatoryFramework, RegulatoryMappingBatchResponse, RegulatoryMappingBatchScope } from '../regulatory-mapping';
const assessorApi = new AssessorApiClient();
@ -18,7 +18,14 @@ export interface RegulatoryMappingBatchController {
start(): Promise<void>;
}
export function useRegulatoryMappingBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined): RegulatoryMappingBatchController {
/** The ISO batch, with the direct BSI batch of the same filters beside it (Phase 13F, BSI stage 2). */
export function useRegulatoryMappingBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined): RegulatoryMappingBatchController & { bsi: RegulatoryMappingBatchController } {
const iso = useFrameworkBatch(requestedProjectId, scope, 'iso27001');
const bsi = useFrameworkBatch(requestedProjectId, scope, 'bsi-kernel');
return { ...iso, bsi };
}
function useFrameworkBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined, framework: RegulatoryFramework): RegulatoryMappingBatchController {
// A switched-off Compliance Engine requests no batch status and offers no batch action.
const available = useEnginePreferences().compliance;
const projectId = available ? requestedProjectId : undefined;
@ -27,7 +34,7 @@ export function useRegulatoryMappingBatch(requestedProjectId: string | undefined
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string>();
const scopeKey = JSON.stringify(scope ?? null);
const requestKey = `${projectId ?? ''}\u0000${scopeKey}`;
const requestKey = `${projectId ?? ''}\u0000${scopeKey}\u0000${framework}`;
const activeRequestKey = useRef(requestKey);
activeRequestKey.current = requestKey;
@ -37,13 +44,13 @@ export function useRegulatoryMappingBatch(requestedProjectId: string | undefined
return;
}
try {
const next = await (poll ? statusPollApi : assessorApi).getRegulatoryMappingBatch(projectId, scope);
const next = await (poll ? statusPollApi : assessorApi).getRegulatoryMappingBatch(projectId, scope, framework);
if (activeRequestKey.current !== requestKey) return;
setResponse(next);
setError(undefined);
} catch (reason) {
if (activeRequestKey.current !== requestKey) return;
setError(reason instanceof Error ? reason.message : 'ISO mapping batch status could not be loaded.');
setError(reason instanceof Error ? reason.message : 'Compliance mapping batch status could not be loaded.');
}
}, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
@ -70,38 +77,59 @@ export function useRegulatoryMappingBatch(requestedProjectId: string | undefined
try {
const next = await assessorApi.startRegulatoryMappingBatch(projectId, {
...scope,
idempotencyKey: `regulatory-batch-${crypto.randomUUID()}`
idempotencyKey: `regulatory-batch-${crypto.randomUUID()}`,
...(framework === 'iso27001' ? {} : { framework })
});
if (activeRequestKey.current !== requestKey) return;
setResponse(next);
} catch (reason) {
if (activeRequestKey.current !== requestKey) return;
setError(reason instanceof Error ? reason.message : 'ISO mapping batch could not be started.');
setError(reason instanceof Error ? reason.message : `${framework === 'bsi-kernel' ? 'BSI' : 'ISO'} mapping batch could not be started.`);
} finally {
if (activeRequestKey.current === requestKey) setStarting(false);
}
}, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
useBackendWorkActivity('Mapping all ISO controls', !response || response.state === 'empty' ? 'idle'
useBackendWorkActivity(framework === 'bsi-kernel' ? 'Mapping all BSI requirements' : 'Mapping all ISO controls', !response || response.state === 'empty' ? 'idle'
: response.state === 'completed' ? 'finished' : response.state);
return { available, response, loading, starting, error, start };
}
export function RegulatoryMappingBatchDialog({ controller, filteredResultCount, onClose }: {
controller: RegulatoryMappingBatchController;
controller: RegulatoryMappingBatchController & { bsi?: RegulatoryMappingBatchController };
filteredResultCount: number;
onClose(): void;
}) {
return <Dialog title="Compliance mapping" onClose={onClose}>
<div className="regulatory-batch-dialog">
<p>Generate ISO control proposals for all <strong>{filteredResultCount.toLocaleString('en-US')}</strong> results matching the current Result DB filters.</p>
<p>Pagination, “Load more,” and loaded-row limits do not restrict this batch. Proposals remain subject to manual Accept/Reject review.</p>
<BatchSection controller={controller} filteredResultCount={filteredResultCount}/>
{/* User decision 2026-10-09: the direct BSI mapping of the same filters, compared with ISO in the Client Info Compliance tab. */}
{controller.bsi && <>
<h3 className="regulatory-batch-heading">BSI Grundschutz++ requirements</h3>
<p>Map the same results directly to BSI Kernel requirements. The Compliance tab compares them with the BSI requirements derived from the accepted ISO controls.</p>
<BatchSection controller={controller.bsi} filteredResultCount={filteredResultCount} startLabel="Start BSI mapping batch"/>
</>}
<div className="dialog-actions">
<button className="btn" type="button" onClick={onClose}>Close</button>
</div>
</div>
</Dialog>;
}
function BatchSection({ controller, filteredResultCount, startLabel = 'Start mapping batch' }: {
controller: RegulatoryMappingBatchController;
filteredResultCount: number;
startLabel?: string;
}) {
const { response, loading, starting, error, start } = controller;
const run = response?.run;
const active = response && ['queued', 'running'].includes(response.state);
const counts = run?.counts;
const actionLabel = run ? 'Run again for current filters' : 'Start mapping batch';
return <Dialog title="ISO mapping batch" onClose={onClose}>
<div className="regulatory-batch-dialog">
<p>Generate ISO control proposals for all <strong>{filteredResultCount.toLocaleString('en-US')}</strong> results matching the current Result DB filters.</p>
<p>Pagination, “Load more,” and loaded-row limits do not restrict this batch. Proposals remain subject to manual Accept/Reject review.</p>
const actionLabel = run ? 'Run again for current filters' : startLabel;
return <>
{loading && !response && <p role="status">Loading batch status…</p>}
{error && <p className="regulatory-mapping-error" role="alert">{error}</p>}
{response && !response.generationEnabled && <p role="status">{response.disabledReason ?? 'Regulatory mapping is unavailable.'}</p>}
@ -120,14 +148,12 @@ export function RegulatoryMappingBatchDialog({ controller, filteredResultCount,
{run.errorDetail && <p className="regulatory-mapping-error-detail">{run.errorDetail}</p>}
</>}
{active && <p role="status">Mapping is running on the server. You can close this dialog and return later.</p>}
<div className="dialog-actions">
<button className="btn" type="button" onClick={onClose}>Close</button>
{response?.generationEnabled && <button className="btn primary" type="button"
{response?.generationEnabled && <div className="dialog-actions">
<button className="btn primary" type="button"
disabled={Boolean(active) || starting || filteredResultCount === 0}
onClick={() => void start()}>{starting ? 'Starting…' : actionLabel}</button>}
</div>
</div>
</Dialog>;
onClick={() => void start()}>{starting ? 'Starting…' : actionLabel}</button>
</div>}
</>;
}
function BatchCount({ label, value }: { label: string; value: number }) {

View File

@ -4,10 +4,17 @@ import './compliance.css';
import type {
RegulatoryMappingCandidate,
RegulatoryMappingDecision,
RegulatoryFramework,
RegulatoryMappingProposal,
RegulatoryMappingResponse
} from '../regulatory-mapping';
// User decision 2026-10-09: the direct BSI mapping uses the same window with its own labels.
const LABELS = {
iso27001: { title: 'ISO control mapping', items: 'controls', evaluating: 'Retrieving and evaluating ISO controls…' },
'bsi-kernel': { title: 'BSI requirement mapping', items: 'requirements', evaluating: 'Retrieving and evaluating BSI requirements…' }
} as const;
interface Props {
response?: RegulatoryMappingResponse;
loading: boolean;
@ -16,16 +23,18 @@ interface Props {
onDecision(control: Pick<RegulatoryMappingCandidate, 'controlId'>, decision: 'accepted' | 'rejected', comment?: string): void;
/** Inside the window the dialog title already names the mapping. */
embedded?: boolean;
framework?: RegulatoryFramework;
}
/** Finding Reader `Map ISO controls` window, opened like `Accepted Risk`. */
/** Finding Reader `Map ISO controls` and `Map BSI requirements` window, opened like `Accepted Risk`. */
export function RegulatoryMappingDialog({ onClose, ...props }: Omit<Props, 'embedded'> & { onClose(): void }) {
return <Dialog title="ISO control mapping" className="regulatory-mapping-dialog" onClose={onClose}>
return <Dialog title={LABELS[props.framework ?? 'iso27001'].title} className="regulatory-mapping-dialog" onClose={onClose}>
<RegulatoryMappingPanel {...props} embedded/>
</Dialog>;
}
export function RegulatoryMappingPanel({ response, loading, error, onStart, onDecision, embedded = false }: Props) {
export function RegulatoryMappingPanel({ response, loading, error, onStart, onDecision, embedded = false, framework = 'iso27001' }: Props) {
const labels = LABELS[framework];
const run = response?.run;
const proposals = run?.result?.mappings ?? [];
const proposalIds = new Set(proposals.map(item => item.controlId));
@ -36,20 +45,20 @@ export function RegulatoryMappingPanel({ response, loading, error, onStart, onDe
const otherCandidates = run?.candidates.filter(item => !proposalIds.has(item.controlId)) ?? [];
const earlierControls = response?.earlierControls ?? [];
return <section className={`regulatory-mapping-panel${embedded ? ' is-embedded' : ''}`} aria-label="ISO control mapping">
<header><div>{!embedded && <h3>ISO control mapping</h3>}<p>Proposals indicate relevance to this finding, not proof of compliance.</p></div>
return <section className={`regulatory-mapping-panel${embedded ? ' is-embedded' : ''}`} aria-label={labels.title}>
<header><div>{!embedded && <h3>{labels.title}</h3>}<p>Proposals indicate relevance to this finding, not proof of compliance.</p></div>
{response?.generationEnabled && !['queued', 'running'].includes(response.state) && <button className="btn" type="button" disabled={loading} onClick={onStart}>{run ? 'Generate again' : 'Generate proposals'}</button>}
</header>
{loading && !response && <p role="status">Loading mapping state…</p>}
{error && <p className="regulatory-mapping-error" role="alert">{error}</p>}
{response && !response.generationEnabled && <p role="status">{response.disabledReason ?? 'Regulatory mapping is unavailable.'}</p>}
{response && ['queued', 'running'].includes(response.state) && <p role="status">{response.state === 'queued' ? 'Mapping queued…' : 'Retrieving and evaluating ISO controls…'}</p>}
{response && ['queued', 'running'].includes(response.state) && <p role="status">{response.state === 'queued' ? 'Mapping queued…' : labels.evaluating}</p>}
{run?.status === 'failed' && <p className="regulatory-mapping-error" role="status">Mapping stopped safely ({run.errorCode ?? 'unknown error'}). You can start a new run.{run.errorDetail ? <><br/><span className="regulatory-mapping-error-detail">{run.errorDetail}</span></> : null}</p>}
{run?.status === 'succeeded' && run.result?.outcome !== 'mapped' && <p role="status">{run.result?.reason ?? (run.result?.outcome === 'insufficient_context' ? 'Insufficient public context.' : 'No relevant control was proposed.')}</p>}
{run?.status === 'succeeded' && run.result?.outcome !== 'mapped' && <p role="status">{run.result?.reason ?? (run.result?.outcome === 'insufficient_context' ? 'Insufficient public context.' : `No relevant ${framework === 'bsi-kernel' ? 'requirement' : 'control'} was proposed.`)}</p>}
{proposedCandidates.length > 0 && <div className="regulatory-control-list">
{proposedCandidates.map(item => <ControlCandidate key={item.candidate.controlId} {...item} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>) }
</div>}
{otherCandidates.length > 0 && <details className="regulatory-other-candidates"><summary>Other retrieved controls ({otherCandidates.length})</summary>
{otherCandidates.length > 0 && <details className="regulatory-other-candidates"><summary>Other retrieved {labels.items} ({otherCandidates.length})</summary>
<div className="regulatory-control-list">{otherCandidates.map(candidate => <ControlCandidate key={candidate.controlId} candidate={candidate} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>)}</div>
</details>}
{earlierControls.length > 0 && <div className="regulatory-earlier-controls" aria-label="Earlier decisions">
@ -73,7 +82,7 @@ function ControlCandidate({ candidate, proposal, decisions, loading, onDecision
{proposal?.rationale && <p>{proposal.rationale}</p>}
<details><summary>Evidence ({candidate.evidence.length})</summary>
<ul className="regulatory-evidence-list">{candidate.evidence.map(item => <li key={item.chunkId}>
{item.sourceStandard} · {item.sectionKind} · {item.pageStart === item.pageEnd ? `page ${item.pageStart}` : `pages ${item.pageStart}–${item.pageEnd}`}
{item.sourceStandard} · {item.pageStart === undefined ? 'catalog text' : <>{item.sectionKind} · {item.pageStart === item.pageEnd ? `page ${item.pageStart}` : `pages ${item.pageStart}–${item.pageEnd}`}</>}
</li>)}</ul>
</details>
</ControlDecision>;

View File

@ -20,6 +20,10 @@
.finding-bsi-requirements__item > summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
.finding-bsi-requirements__item > summary strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
.finding-bsi-requirements__statement { margin: var(--space-1) 0 var(--space-2) var(--space-5); font-size: var(--type-body-size); line-height: 1.5; }
/* Direct BSI mapping (stage 2): its own group below the derived ones, with the window action beside the heading. */
.finding-bsi-requirements__direct-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2); }
div.finding-bsi-requirements__item { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; }
div.finding-bsi-requirements__item strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* Client Info `Compliance` tab; its tiles use the environment tile geometry. */
.client-compliance-page { align-content: start; }
@ -156,3 +160,27 @@ button.compliance-footprint__cell:focus-visible { outline: var(--bw-emphasis) so
.compliance-coverage__legend dd button { padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; cursor: pointer; }
.compliance-coverage__legend dd button::after { content: ''; position: absolute; inset: 0; }
.compliance-coverage__legend dd button:focus-visible { outline: none; }
/* ISO <-> BSI review window (Phase 13F, BSI stage 2): rules as disclosure rows, no status colour. */
.mapping-review { display: grid; gap: var(--space-3); min-width: 0; }
.mapping-review__intro, .mapping-review__note { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }
.mapping-review__note { color: var(--text-secondary); }
.mapping-review__items { display: grid; min-width: 0; border-top: var(--border-hair) solid var(--tile-divider); }
.mapping-review__item { min-width: 0; border-bottom: var(--border-hair) solid var(--tile-divider); }
.mapping-review__item > summary { display: grid; grid-template-columns: minmax(150px, auto) minmax(0, 1fr); gap: var(--space-1) var(--space-3); padding: var(--space-2) 0; cursor: pointer; list-style: none; }
.mapping-review__item > summary::-webkit-details-marker { display: none; }
.mapping-review__item > summary:hover { background: var(--tile-surface-hover); }
.mapping-review__item > summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(var(--bw-emphasis) * -1); }
.mapping-review__state { font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
.mapping-review__title { font-size: var(--type-body-size); overflow-wrap: break-word; min-width: 0; }
.mapping-review__meta { font-size: var(--type-table-body-size); color: var(--text-secondary); overflow-wrap: anywhere; }
.mapping-review__item > summary .mapping-review__meta { grid-column: 2; }
.mapping-review__detail { display: grid; gap: var(--space-3); padding: 0 0 var(--space-3); }
.mapping-review__side h4 { margin: 0 0 var(--space-1); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
.mapping-review__side p { margin: 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }
.mapping-review__side ul { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
.mapping-review__side li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); font-size: var(--type-table-body-size); }
.mapping-review__side li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mapping-review__side li > span:not(.mapping-review__meta) { flex: 1 1 200px; min-width: 0; overflow-wrap: break-word; }
.mapping-review__actions { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-2); }
.mapping-review__actions label { display: grid; flex: 1 1 260px; gap: var(--space-1); font-size: var(--type-table-body-size); }

View File

@ -1,6 +1,6 @@
import { readEnginePreferences } from '../../app/engine-preferences';
import { beginActivity } from '../../platform/activity-store';
import type { RegulatoryMappingResponse } from '../regulatory-mapping';
import type { RegulatoryFramework, RegulatoryMappingResponse } from '../regulatory-mapping';
import { statusPollApi } from './status-poll-client';
const tracked = new Set<string>();
@ -8,7 +8,9 @@ const listeners = new Set<(key: string, response: RegulatoryMappingResponse) =>
const isActive = (response: RegulatoryMappingResponse) => response.state === 'queued' || response.state === 'running';
const wait = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds));
export const mappingRunKey = (projectId: string, resultProjectionId: string) => `${projectId}\u0000${resultProjectionId}`;
// ISO keys keep their earlier form; a BSI mapping of the same finding is tracked separately.
export const mappingRunKey = (projectId: string, resultProjectionId: string, framework: RegulatoryFramework = 'iso27001') =>
`${projectId}\u0000${resultProjectionId}${framework === 'iso27001' ? '' : `\u0000${framework}`}`;
/** Views showing a mapping receive the tracker's status updates instead of polling themselves. */
export function subscribeRegulatoryMappingRuns(listener: (key: string, response: RegulatoryMappingResponse) => void): () => void {
@ -22,10 +24,12 @@ export function subscribeRegulatoryMappingRuns(listener: (key: string, response:
* Engine off stops observing; the backend work itself is never cancelled.
*/
export function trackRegulatoryMappingRun(projectId: string, resultProjectionId: string, response: RegulatoryMappingResponse): void {
const key = mappingRunKey(projectId, resultProjectionId);
const framework = response.framework ?? 'iso27001';
const key = mappingRunKey(projectId, resultProjectionId, framework);
if (!isActive(response) || tracked.has(key)) return;
tracked.add(key);
const activity = beginActivity('Compliance Engine', 'Mapping ISO controls', `compliance-mapping:${key}`);
const label = framework === 'bsi-kernel' ? 'Mapping BSI requirements' : 'Mapping ISO controls';
const activity = beginActivity('Compliance Engine', label, `compliance-mapping:${key}`);
if (response.state === 'queued') activity.queue();
void (async () => {
let current = response;
@ -33,18 +37,18 @@ export function trackRegulatoryMappingRun(projectId: string, resultProjectionId:
while (isActive(current)) {
await wait(current.pollAfterMs ?? 1_500);
if (!readEnginePreferences().compliance) {
activity.update('Mapping ISO controls continues in the backend');
activity.update(`${label} continues in the backend`);
activity.complete();
return;
}
current = await statusPollApi.getRegulatoryMappings(projectId, resultProjectionId);
current = await statusPollApi.getRegulatoryMappings(projectId, resultProjectionId, framework);
if (current.state === 'running') activity.run();
listeners.forEach(listener => listener(key, current));
}
if (current.state === 'failed') activity.fail();
else activity.complete();
} catch {
activity.update('Mapping ISO controls: status could not be checked');
activity.update(`${label}: status could not be checked`);
activity.fail();
} finally {
tracked.delete(key);

View File

@ -10,6 +10,7 @@ import {
acceptedControlIds,
type RegulatoryMappingCandidate,
type RegulatoryMappingDecisionValue,
type RegulatoryFramework,
type RegulatoryMappingResponse
} from '../regulatory-mapping';
@ -26,7 +27,9 @@ export function useRegulatoryMapping(
projectId: string,
/** Result DB row of the record: a scanner finding occurrence or a GPO baseline comparison. */
resultProjectionId: string,
backendStatus: BackendStatus
backendStatus: BackendStatus,
/** ISO controls by default; `bsi-kernel` for the direct BSI mapping of the same finding (Phase 13F, stage 2). */
framework: RegulatoryFramework = 'iso27001'
) {
// A switched-off Compliance Engine shows no control, opens nothing and sends no request.
const available = useEnginePreferences().compliance;
@ -41,7 +44,7 @@ export function useRegulatoryMapping(
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}`;
const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}\u0000${framework}`;
const activeScope = useRef({ key: scopeKey });
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
const requestScope = activeScope.current;
@ -58,7 +61,7 @@ export function useRegulatoryMapping(
if (!projectId || !resultProjectionId || backendStatus !== 'connected' || activeScope.current !== requestScope) return;
setLoading(true);
try {
const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId);
const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId, framework);
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
@ -69,20 +72,21 @@ export function useRegulatoryMapping(
} finally {
if (activeScope.current === requestScope) setLoading(false);
}
}, [backendStatus, projectId, requestScope, resultProjectionId]);
}, [backendStatus, framework, projectId, requestScope, resultProjectionId]);
// Loaded with the finding, so the reader can name its accepted controls. A running mapping is polled
// once by the run tracker, which keeps its activity entry until the backend ends it.
useEffect(() => { if (available && !response && !loading && !error) void load(); }, [available, error, load, loading, response]);
useEffect(() => subscribeRegulatoryMappingRuns((key, next) => {
if (key === mappingRunKey(projectId, resultProjectionId) && activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next });
}), [projectId, requestScope, resultProjectionId]);
if (key === mappingRunKey(projectId, resultProjectionId, framework) && activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next });
}), [framework, projectId, requestScope, resultProjectionId]);
const start = useCallback(async () => {
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
setLoading(true);
try {
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId, origin: origin.current });
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId, origin: origin.current,
...(framework === 'iso27001' ? {} : { framework }) });
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
@ -93,7 +97,7 @@ export function useRegulatoryMapping(
} finally {
if (activeScope.current === requestScope) setLoading(false);
}
}, [projectId, requestScope, resultProjectionId]);
}, [framework, projectId, requestScope, resultProjectionId]);
const decide = useCallback(async (candidate: Pick<RegulatoryMappingCandidate, 'controlId'>, decision: RegulatoryMappingDecisionValue, comment?: string) => {
if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return;
@ -102,6 +106,7 @@ export function useRegulatoryMapping(
try {
const next = await assessorApi.saveRegulatoryMappingDecision(projectId, {
resultProjectionId,
...(framework === 'iso27001' ? {} : { framework }),
controlId: candidate.controlId,
decision,
...(comment?.trim() ? { comment: comment.trim() } : {}),
@ -117,16 +122,17 @@ export function useRegulatoryMapping(
} finally {
if (activeScope.current === requestScope) setLoading(false);
}
}, [projectId, requestScope, response, resultProjectionId]);
}, [framework, projectId, requestScope, response, resultProjectionId]);
const acceptedControls = useMemo<AcceptedRegulatoryControl[]>(() => available && response
? acceptedControlIds(response.decisions).map(controlId => {
// A control decided in an earlier run keeps its title after the current run stopped retrieving it.
const candidate = response.run?.candidates.find(item => item.controlId === controlId)
?? response.earlierControls?.find(item => item.controlId === controlId);
return { controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, ...(candidate?.title ? { title: candidate.title } : {}) };
return { controlId, annexAControlId: candidate?.annexAControlId ?? (framework === 'iso27001' ? `A.${controlId}` : controlId),
...(candidate?.title ? { title: candidate.title } : {}) };
}).sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }))
: [], [available, response]);
: [], [available, framework, response]);
const localDisabledReason = !resultProjectionId ? 'This record has no stored finding or current GPO baseline comparison.'
: backendStatus !== 'connected' ? 'The backend is not connected.'

View File

@ -556,8 +556,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)}
{/* Compliance Engine: controls accepted for this finding, read like Description. */}
<FindingIsoControls controls={regulatoryMapping.acceptedControls}/>
{/* Compliance Engine: BSI requirements derived from those controls through the BSI mapping. */}
<FindingBsiRequirements bsi={regulatoryMapping.bsi}/>
{/* Compliance Engine: BSI requirements derived from those controls through the BSI mapping, and the direct BSI mapping. */}
<FindingBsiRequirements bsi={regulatoryMapping.bsi} projectId={projectId} resultProjectionId={regulatoryMappingResultId(record)} backendStatus={backendStatus}/>
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'

View File

@ -182,8 +182,8 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
<button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
{regulatoryBatch.available && <button className="btn" type="button"
disabled={loading || regulatoryBatch.loading || Boolean(regulatoryBatch.error) || !queryResult?.total || regulatoryBatch.response?.generationEnabled === false}
title={regulatoryBatch.error ?? regulatoryBatch.response?.disabledReason ?? 'Generate ISO control proposals for every result matching the current filters'}
onClick={() => setBatchDialogOpen(true)}>Map all ISO controls</button>}
title={regulatoryBatch.error ?? regulatoryBatch.response?.disabledReason ?? 'Generate ISO and BSI proposals for every result matching the current filters'}
onClick={() => setBatchDialogOpen(true)}>Compliance mapping</button>}
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
onClick={() => void exportFilteredResults('jsonl')}>{exporting === 'jsonl' ? 'Exporting …' : 'Export JSONL'}</button>
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}

View File

@ -981,6 +981,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.regulatory-batch-counts dt { color: var(--muted); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); }
.regulatory-batch-counts dd { margin: 0; color: var(--heading); font-variant-numeric: tabular-nums; font-weight: var(--ui-strong-weight); }
.regulatory-batch-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: var(--legacy-space-2); padding-top: var(--legacy-space-2); }
.regulatory-batch-heading { margin: 0; padding-top: var(--legacy-space-3); border-top: var(--border-hair) solid var(--line); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
@media (max-width: 1150px) {
.results-browser-layout { grid-template-columns: 1fr; }
.results-filter-panel { position: static; max-height: none; }

View File

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

View File

@ -6,7 +6,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import { BsiCatalogError, type BsiCatalogService } from '../../server/compliance/bsi-catalog-service';
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
import type { BsiCatalogState, BsiSearchIndexStatus } from '../../src/compliance/bsi-catalog-management';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
// Data Hub `Update from BSI` routes; the service is a stub with synthetic states.
@ -20,12 +20,12 @@ describe('BSI catalog routes', () => {
let baseUrl: string;
const service = { current: vi.fn(), refresh: vi.fn(), reset: vi.fn() };
const start = async (bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>) => {
server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, bsiCatalogService });
const start = async (bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>, bsiSearchIndex?: object) => {
server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, bsiCatalogService, bsiSearchIndex: bsiSearchIndex as never });
await new Promise<void>((resolve, reject) => { server!.once('error', reject); server!.listen(0, '127.0.0.1', resolve); });
baseUrl = `http://127.0.0.1:${(server!.address() as AddressInfo).port}/api/v1/reference-data/bsi-grundschutz`;
};
const call = (suffix = '', method = 'GET') => fetch(`${baseUrl}${suffix}`, method === 'GET' ? {} : { method, headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: new URL(baseUrl).origin }, body: '{}' });
const call = (suffix = '', method = 'GET', body = '{}') => fetch(`${baseUrl}${suffix}`, method === 'GET' ? {} : { method, headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: new URL(baseUrl).origin }, body });
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-bsi-http-'));
@ -66,4 +66,22 @@ describe('BSI catalog routes', () => {
expect(unavailable.status).toBe(503);
expect(await unavailable.json()).toMatchObject({ error: { code: 'bsi_catalog_unavailable' } });
});
it('reports the BSI search index and starts a build or a forced rebuild from the Data Hub', async () => {
const indexStatus: BsiSearchIndexStatus = { version: 1, state: 'not-built', catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic',
requirementCount: 2, indexedChunks: 0 };
const index = { status: vi.fn(async () => indexStatus), startBuild: vi.fn(async () => ({ ...indexStatus, state: 'building' as const })) };
await start(service as never, index);
const read = await call('/search-index');
expect(read.status).toBe(200);
expect(await read.json()).toEqual(indexStatus);
const build = await call('/search-index/build', 'POST');
expect(build.status).toBe(202);
expect(await build.json()).toMatchObject({ state: 'building' });
await call('/search-index/build', 'POST', JSON.stringify({ force: true }));
expect(index.startBuild.mock.calls).toEqual([[false], [true]]);
await new Promise<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
await start(service as never);
expect((await call('/search-index')).status).toBe(503);
});
});

View File

@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
import type { BsiCatalogState, BsiSearchIndexStatus } from '../../src/compliance/bsi-catalog-management';
import { BsiCatalogLibrary } from '../../src/compliance/ui/BsiCatalogLibrary';
import { useRegulatoryDecisionRevision } from '../../src/compliance/ui/decision-revision';
@ -88,6 +88,47 @@ describe('Data Hub BSI catalog tile', () => {
expect(button('Use bundled catalog')).toBeUndefined();
});
it('shows the search index state and builds it from the Data Hub, following its progress', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
const index = (state: BsiSearchIndexStatus['state'], extra: Partial<BsiSearchIndexStatus> = {}): BsiSearchIndexStatus => ({ version: 1, state,
catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic', requirementCount: 901, indexedChunks: 0, ...extra });
const status = vi.spyOn(AssessorApiClient.prototype, 'getBsiSearchIndex').mockResolvedValue(index('not-built'));
const build = vi.spyOn(AssessorApiClient.prototype, 'buildBsiSearchIndex').mockResolvedValue(index('building', { progress: { completed: 64, total: 1167 } }));
const indexLine = () => host.querySelector('[data-bsi-search-index]')?.textContent;
await render();
expect(indexLine()).toBe('Search index Not built · the first BSI mapping builds it otherwise');
vi.useFakeTimers();
try {
status.mockResolvedValue(index('built', { indexedChunks: 1167 }));
await act(async () => button('Build search index')!.click());
expect(build).toHaveBeenCalledWith(false);
expect(indexLine()).toBe('Search index Building · 64 of 1,167 chunks embedded');
expect(button('Building search index…')?.disabled).toBe(true);
expect(button('Update from BSI')?.disabled).toBe(true);
await act(async () => { await vi.advanceTimersByTimeAsync(2_000); });
expect(indexLine()).toBe('Search index Built · 1,167 chunks for 901 requirements');
} finally { vi.useRealTimers(); }
// A complete index is rebuilt on request.
await act(async () => button('Rebuild search index')!.click());
expect(build).toHaveBeenLastCalledWith(true);
});
it('names an outdated index and hides the build action while embedding is not configured', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
const status = vi.spyOn(AssessorApiClient.prototype, 'getBsiSearchIndex').mockResolvedValue({ version: 1, state: 'outdated',
catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic', requirementCount: 901, indexedChunks: 0 });
await render();
expect(host.querySelector('[data-bsi-search-index]')?.textContent).toBe('Search index Outdated · built for another catalog state');
expect(button('Build search index')).toBeDefined();
status.mockResolvedValue({ version: 1, state: 'unavailable', catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic',
requirementCount: 901, indexedChunks: 0, disabledReason: 'Embedding or Qdrant is not configured.' });
await act(async () => root.unmount());
root = createRoot(host);
await render();
expect(button('Build search index')).toBeUndefined();
expect(host.querySelector('[data-bsi-catalog] .datahub-alert')?.textContent).toBe('Embedding or Qdrant is not configured.');
});
it('renders and requests nothing while the Compliance Engine is off', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
setEngineEnabled('compliance', false);

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { bsiConsistency, bsiConsistencyFingerprintSource } from '../../src/compliance/bsi-consistency';
import type { BsiIsoCrosswalk } from '../../src/compliance/bsi-grundschutz';
// Synthetic BSI mapping: two ISO controls reach three requirements; SYN.9.1 is reachable from no ISO control.
const crosswalk: BsiIsoCrosswalk = {
schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: 'Synthetic', version: '0.1-synthetic',
sourceUrl: 'https://example.invalid/synthetic', sourceSha256: '0'.repeat(64), license: 'Synthetic', targetCatalog: 'synthetic',
entries: [
{ uuid: 'a', isoControlId: '8.9', relationship: 'subset-of', targets: ['KONF.1.1', 'KONF.2.1'] },
{ uuid: 'b', isoControlId: '5.15', relationship: 'equivalent-to', targets: ['BER.3.5'] }
]
};
describe('ISO <-> BSI consistency', () => {
it('has nothing to compare while no BSI requirement is accepted directly', () => {
expect(bsiConsistency(['8.9'], [], crosswalk)).toBeUndefined();
});
it('is consistent when one direct requirement lies in the set derived from the accepted ISO controls', () => {
// subset-of reaches two requirements; one hit is enough, and an extra direct requirement does not break it.
expect(bsiConsistency(['8.9'], ['KONF.2.1', 'SYN.9.1'], crosswalk)).toMatchObject({
state: 'consistent', overlapRequirementIds: ['KONF.2.1'], unreachableRequirementIds: ['SYN.9.1'],
derivedRequirementIds: ['KONF.1.1', 'KONF.2.1'], reverseCandidates: []
});
});
it('is inconsistent when a reachable direct requirement is not derived, and names the ISO control the BSI mapping points to', () => {
expect(bsiConsistency(['8.9'], ['BER.3.5'], crosswalk)).toMatchObject({
state: 'inconsistent', overlapRequirementIds: [], reverseCandidates: [{ isoControlId: '5.15', requirementIds: ['BER.3.5'] }]
});
// Without any accepted ISO control, a reachable direct requirement is a disagreement as well.
expect(bsiConsistency([], ['KONF.1.1'], crosswalk)?.state).toBe('inconsistent');
});
it('reports no ISO reference when no direct requirement is reachable from any ISO control', () => {
expect(bsiConsistency(['8.9'], ['SYN.9.1'], crosswalk)).toMatchObject({ state: 'no-reference', unreachableRequirementIds: ['SYN.9.1'], reverseCandidates: [] });
});
it('fingerprints the compared sets independent of order, and changes with a decision or the mapping version', () => {
const base = bsiConsistencyFingerprintSource(['8.9', '5.15'], ['BER.3.5', 'KONF.1.1'], '0.1');
expect(bsiConsistencyFingerprintSource(['5.15', '8.9', '8.9'], ['KONF.1.1', 'BER.3.5'], '0.1')).toBe(base);
expect(bsiConsistencyFingerprintSource(['8.9'], ['BER.3.5', 'KONF.1.1'], '0.1')).not.toBe(base);
expect(bsiConsistencyFingerprintSource(['8.9', '5.15'], ['BER.3.5', 'KONF.1.1'], '0.2')).not.toBe(base);
});
});

View File

@ -0,0 +1,274 @@
import { describe, expect, it, vi } from 'vitest';
import { AUTO_ACCEPT_COMMENT, RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service';
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
import { bsiCorpusDocuments, bsiCorpusVersion, BsiCorpus } from '../../server/compliance/bsi-corpus';
import type { BsiCatalogs } from '../../server/compliance/bsi-snapshots';
import { acceptedControlIds, BSI_CORPUS_ID } from '../../src/compliance/regulatory-mapping';
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import type { ComplianceReadPort } from '../../src/compliance/compliance-store';
import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
const stamp = '2026-10-09T10:00:00.000Z';
// Synthetic BSI catalog and mapping: ISO 8.9 reaches KONF.1.1 and KONF.2.1, ISO 5.15 reaches BER.3.5, SYN.9.1 is unreachable.
const requirement = (id: string, practice: string) => ({ id, title: `Synthetische Anforderung ${id}`, practice,
statement: `Synthetische Aussage {{ insert: param, ${id}-p }} für ${id}.`, modalVerb: 'MUSS', params: { [`${id}-p`]: 'Wert' } });
const catalogs: BsiCatalogs = {
catalog: { schemaVersion: 1, framework: 'bsi-grundschutz-plus-plus', catalogClass: 'BSI-Stand-der-Technik-Kernel-G0', title: 'Synthetic',
version: '2026-01-01T00:00:00+00:00', sourceUrl: 'https://example.invalid/catalog', sourceSha256: 'c'.repeat(64), license: 'Synthetic',
practices: [{ id: 'KONF', title: 'Konfiguration' }, { id: 'BER', title: 'Berechtigung' }, { id: 'SYN', title: 'Synthetisch' }],
requirements: [requirement('KONF.1.1', 'KONF'), requirement('KONF.2.1', 'KONF'), requirement('BER.3.5', 'BER'), requirement('SYN.9.1', 'SYN')] },
crosswalk: { schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: 'Synthetic', version: '0.1-synthetic',
sourceUrl: 'https://example.invalid/mapping', sourceSha256: 'd'.repeat(64), license: 'Synthetic', targetCatalog: 'synthetic',
entries: [{ uuid: 'a', isoControlId: '8.9', relationship: 'subset-of', targets: ['KONF.1.1', 'KONF.2.1'] },
{ uuid: 'b', isoControlId: '5.15', relationship: 'equivalent-to', targets: ['BER.3.5'] }] }
};
const corpusVersion = bsiCorpusVersion(catalogs.catalog);
function finding(id: string, ruleId: string): UnifiedResultProjection {
return {
resultProjectionId: `scanner-finding:${id}`, sourceKind: 'scanner-finding', sourceResultId: id,
projectId: 'project-1', assessmentId: 'assessment-1', collectionRunId: 'run-1', scanRunId: 'run-1',
sourceTool: 'purple-knight', sourceRuleId: ruleId, title: `Synthetic configuration check ${ruleId}`,
description: 'A synthetic configuration setting is weaker than recommended.',
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
technicalExecutionState: 'completed', resultType: 'compliance_check', isIoe: false, isDeviation: true,
lifecycleState: 'new', mappingConfidence: 1, reviewStatus: 'unreviewed', importedAt: stamp, createdAt: stamp, frameworkReferences: [], categories: []
};
}
function readPort(findings: UnifiedResultProjection[]): ComplianceReadPort {
return {
getProject: async projectId => projectId === 'project-1' ? { id: projectId, name: 'Synthetic project' } as never : null,
listProjects: async () => [{ id: 'project-1', name: 'Synthetic project' }] as never,
listAssessments: async () => [{ id: 'assessment-1', projectId: 'project-1', label: 'Synthetic', createdAt: stamp }] as never,
listProjectScanRuns: async () => [{ id: 'run-1', assessmentId: 'assessment-1', sequence: 1, startedAt: stamp }] as never,
queryUnifiedResults: async (query = {}) => {
const filtered = filterUnifiedResults(findings, query);
const offset = query.offset ?? 0;
return { total: filtered.length, items: filtered.slice(offset, offset + (query.limit ?? filtered.length)) } as never;
},
getUnifiedResultById: async (id, projectId) => {
const item = findings.find(entry => entry.resultProjectionId === id && entry.projectId === projectId);
return item ? removeResultDbScopeIdentifiers(item) : null;
}
};
}
function bsiChunk(controlId: string): RegulatoryChunk {
return { schemaVersion: 1, corpusId: BSI_CORPUS_ID, corpusVersion, documentId: `bsi-kernel:${controlId}`, framework: 'BSI Grundschutz++',
frameworkVersion: catalogs.catalog.version, controlId, chunkId: `bsi-kernel:${controlId}:0:synthetic`, chunkIndex: 0, language: 'de',
title: `Synthetische Anforderung ${controlId}`, text: `Synthetische Aussage Wert für ${controlId}.`, textSha256: 'b'.repeat(64) };
}
function isoChunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002', controlId: string): RegulatoryChunk {
const reference = sourceStandard === 'ISO/IEC 27001';
return { schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: `${reference ? '27001' : '27002'}-${controlId}`,
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId, annexAControlId: `A.${controlId}`, controlTheme: 'technological',
sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024', sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test', sourceSha256: reference ? '1'.repeat(64) : '2'.repeat(64),
chunkId: `${reference ? 'reference' : 'semantic'}-${controlId}`, chunkIndex: 0, language: 'de', title: 'Synthetic control',
text: 'Synthetic evidence.', textSha256: 'a'.repeat(64) };
}
function setup(findings = [finding('occurrence-1', 'SYN-CONFIG')]) {
const store = new LocalComplianceStore();
// Retrieval answers each framework from its own corpus; ISO always retrieves 8.9.
const retrieve = vi.fn(async (request: RegulatoryRetrievalRequest) => request.filters?.corpusId === BSI_CORPUS_ID
? ['KONF.2.1', 'BER.3.5', 'SYN.9.1'].map((id, index) => ({ score: 0.9 - index / 10, chunk: bsiChunk(id) }))
: [{ score: 0.9, chunk: isoChunk('ISO/IEC 27002', '8.9') }]);
const list = vi.fn(async (filters: { corpusId?: string; controlId?: string }) => filters.corpusId === BSI_CORPUS_ID
? [bsiChunk(filters.controlId!)] : [isoChunk('ISO/IEC 27001', filters.controlId ?? '8.9')]);
const complete = vi.fn(async (system: string, input: string) => {
const bsi = system.includes('BSI Grundschutz++');
const controlId = bsi ? 'KONF.2.1' : '8.9';
const evidence = JSON.parse(input).candidates.find((candidate: { controlId: string }) => candidate.controlId === controlId).evidence
.map((item: { chunkId: string }) => item.chunkId);
return { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId, relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: evidence }] } };
});
const ensure = vi.fn(async () => corpusVersion);
const svc = new RegulatoryMappingService(readPort(findings), store, {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list } as never,
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3',
bsiCatalogs: async () => catalogs, bsiCorpus: { configured: true, ensure } as unknown as BsiCorpus,
now: () => new Date(stamp)
});
return { store, svc, complete, ensure, retrieve };
}
const scope = { assessmentIds: ['assessment-1'], scanRunIds: [] as string[] };
const id = 'scanner-finding:occurrence-1';
describe('direct BSI mapping and the ISO <-> BSI review', () => {
it('maps a rule directly to BSI requirements beside its ISO mapping, keeps the decisions apart and accepts the first proposals', async () => {
const { store, svc, complete, ensure } = setup();
await svc.start('project-1', { resultProjectionId: id });
await svc.start('project-1', { resultProjectionId: id, framework: 'bsi-kernel' });
await svc.close();
const iso = await svc.get('project-1', id);
const bsi = await svc.get('project-1', id, 'bsi-kernel');
// Two runs of one rule; the ISO run keeps its earlier shape without a framework field.
expect(iso.run).toMatchObject({ status: 'succeeded', corpusId: 'iso-test' });
expect(iso.run).not.toHaveProperty('framework');
expect(bsi.run).toMatchObject({ status: 'succeeded', framework: 'bsi-kernel', corpusId: BSI_CORPUS_ID, corpusVersion, retrievalVersion: 'bsi-hybrid-rrf-de-1.0.0' });
expect(bsi.run!.id).not.toBe(iso.run!.id);
// The BSI candidates carry their requirement id, catalog text evidence without pages, and the model received BSI instructions.
expect(bsi.run!.candidates.map(candidate => candidate.controlId)).toEqual(['KONF.2.1', 'BER.3.5', 'SYN.9.1']);
expect(bsi.run!.candidates[0]).toMatchObject({ annexAControlId: 'KONF.2.1', evidence: [{ sourceStandard: 'BSI Grundschutz++', sectionKind: 'control' }] });
expect(bsi.run!.candidates[0].evidence[0]).not.toHaveProperty('pageStart');
expect(complete.mock.calls.filter(([system]) => system.includes('BSI Grundschutz++'))).toHaveLength(1);
expect(ensure).toHaveBeenCalledTimes(1);
// User decision 2026-10-09: the first proposals are accepted automatically in both frameworks, each in its own decision set.
expect(acceptedControlIds(iso.decisions)).toEqual(['8.9']);
expect(acceptedControlIds(bsi.decisions)).toEqual(['KONF.2.1']);
expect(bsi.decisions[0]).toMatchObject({ framework: 'bsi-kernel', comment: AUTO_ACCEPT_COMMENT });
expect(await store.listRegulatoryMappingDecisions('purple-knight:SYN-CONFIG')).toHaveLength(1);
// The derived stage 1 overview still follows the accepted ISO control only; the BSI response names the consistency.
expect(iso.bsi?.requirements.map(item => item.requirementId)).toEqual(['KONF.1.1', 'KONF.2.1']);
expect(bsi.consistency).toEqual({ state: 'consistent', reviewed: false });
});
it('lists the rule in the review, reports a disagreement after a decision, keeps a reviewed result until it changes, and changes no decision itself', async () => {
const { store, svc } = setup();
await svc.start('project-1', { resultProjectionId: id });
await svc.start('project-1', { resultProjectionId: id, framework: 'bsi-kernel' });
await svc.close();
expect((await svc.consistency('project-1', scope)).counts).toEqual({ consistent: 1, inconsistent: 0, noReference: 0, notCompared: 0, reviewed: 0 });
// A person rejects the direct KONF.2.1 and accepts BER.3.5, which only ISO 5.15 reaches.
const latest = async (controlId: string) => (await svc.get('project-1', id, 'bsi-kernel')).decisions.find(item => item.controlId === controlId)?.id;
await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'KONF.2.1', decision: 'rejected', expectedLatestDecisionId: await latest('KONF.2.1') });
await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'BER.3.5', decision: 'accepted' });
const isoBefore = await store.listRegulatoryMappingDecisions('purple-knight:SYN-CONFIG');
const review = await svc.consistency('project-1', scope);
expect(review.counts).toMatchObject({ consistent: 0, inconsistent: 1, reviewed: 0 });
expect(review.items).toHaveLength(1);
expect(review.items[0]).toMatchObject({
resultProjectionId: id, sourceTool: 'purple-knight', sourceRuleId: 'SYN-CONFIG', findingCount: 1, state: 'inconsistent', reviewed: false,
iso: [{ controlId: '8.9', title: 'Synthetic control' }], derivedCount: 2,
direct: [{ requirementId: 'BER.3.5', title: 'Synthetische Anforderung BER.3.5', practice: 'BER', derived: false, reachable: true }],
// 5.15 was not retrieved by the ISO mapping, so it cannot be accepted from the review.
reverse: [{ controlId: '5.15', requirementIds: ['BER.3.5'], decidable: false }]
});
// The review names rules only by a Result DB row of the timeslot, never by the internal rule key.
expect(JSON.stringify(review)).not.toContain('purple-knight:SYN-CONFIG');
await svc.markConsistencyReviewed('project-1', { resultProjectionId: id, comment: 'Kept on purpose.' });
await svc.markConsistencyReviewed('project-1', { resultProjectionId: id });
expect((await store.listRegulatoryConsistencyReviews())).toHaveLength(1);
expect((await svc.consistency('project-1', scope)).items[0]).toMatchObject({ state: 'inconsistent', reviewed: true });
expect((await svc.get('project-1', id, 'bsi-kernel')).consistency).toEqual({ state: 'inconsistent', reviewed: true });
// Neither the review nor the disagreement touched an ISO decision.
expect(await store.listRegulatoryMappingDecisions('purple-knight:SYN-CONFIG')).toEqual(isoBefore);
// A further decision changes the compared sets, so the rule needs a review again.
await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'SYN.9.1', decision: 'accepted' });
expect((await svc.consistency('project-1', scope)).items[0]).toMatchObject({ state: 'inconsistent', reviewed: false });
});
it('counts a rule with an accepted ISO control but no BSI mapping as not compared, and reports no ISO reference for unreachable requirements', async () => {
const { svc } = setup([finding('occurrence-1', 'SYN-CONFIG'), finding('occurrence-2', 'SYN-OTHER')]);
await svc.start('project-1', { resultProjectionId: id });
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-2' });
await svc.start('project-1', { resultProjectionId: id, framework: 'bsi-kernel' });
await svc.close();
const latest = (await svc.get('project-1', id, 'bsi-kernel')).decisions[0].id;
await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'KONF.2.1', decision: 'rejected', expectedLatestDecisionId: latest });
await svc.decide('project-1', { resultProjectionId: id, framework: 'bsi-kernel', controlId: 'SYN.9.1', decision: 'accepted' });
const review = await svc.consistency('project-1', scope);
expect(review.counts).toEqual({ consistent: 0, inconsistent: 0, noReference: 1, notCompared: 1, reviewed: 0 });
expect(review.items.map(item => [item.sourceRuleId, item.state])).toEqual([['SYN-CONFIG', 'no-reference']]);
});
it('runs a BSI batch beside an ISO batch of the same scope and lists both in the mapping history', async () => {
const { svc } = setup();
await svc.startBatch('project-1', { ...scope, filters: { deviation: true }, idempotencyKey: 'synthetic-iso-batch' });
await svc.startBatch('project-1', { ...scope, filters: { deviation: true }, idempotencyKey: 'synthetic-bsi-batch', framework: 'bsi-kernel' });
await svc.close();
const isoBatch = await svc.getBatch('project-1', { ...scope, filters: { deviation: true } });
const bsiBatch = await svc.getBatch('project-1', { ...scope, filters: { deviation: true } }, 'bsi-kernel');
expect(isoBatch.run).toMatchObject({ status: 'completed', idempotencyKey: 'synthetic-iso-batch', counts: { succeededRuleCount: 1 } });
expect(isoBatch.run).not.toHaveProperty('framework');
expect(bsiBatch.run).toMatchObject({ status: 'completed', framework: 'bsi-kernel', idempotencyKey: 'synthetic-bsi-batch', counts: { succeededRuleCount: 1 } });
expect(bsiBatch.run!.scopeKey).not.toBe(isoBatch.run!.scopeKey);
expect(acceptedControlIds((await svc.get('project-1', id, 'bsi-kernel')).decisions)).toEqual(['KONF.2.1']);
const overview = await svc.controlOverview('project-1', scope);
// User decision 2026-10-09: the history names each mapping's framework.
expect(overview.mappingHistory?.map(entry => [entry.id, entry.framework]).sort())
.toEqual([[bsiBatch.run!.id, 'bsi-kernel'], [isoBatch.run!.id, 'iso27001']].sort());
// The ISO overview counts only ISO controls; a BSI requirement never appears as an ISO control.
expect(overview.controls.map(control => control.controlId)).toEqual(['8.9']);
});
it('rejects an unknown framework', async () => {
const { svc } = setup();
await expect(svc.get('project-1', id, 'dora' as never)).rejects.toThrow('framework must be iso27001 or bsi-kernel.');
});
});
describe('BSI Kernel corpus', () => {
it('indexes every requirement once per catalog state with its parameters filled in, and reuses a complete index', async () => {
const ingest = vi.fn(async () => ({ documentCount: 4, chunkCount: 4, embeddingModel: 'bge-m3' }));
let indexed = 0;
const prune = vi.fn(async () => undefined);
const corpus = new BsiCorpus({ catalogs: async () => catalogs, ingestion: { ingest } as never,
countIndexed: async () => indexed, pruneIndexed: prune });
const documents = bsiCorpusDocuments(catalogs.catalog);
expect(documents).toHaveLength(4);
expect(documents[0]).toMatchObject({ corpusId: BSI_CORPUS_ID, corpusVersion, controlId: 'KONF.1.1', language: 'de',
text: 'Synthetische Aussage Wert für KONF.1.1.' });
await Promise.all([corpus.ensure(), corpus.ensure()]);
expect(ingest).toHaveBeenCalledTimes(1);
expect(prune).toHaveBeenCalledWith(BSI_CORPUS_ID, corpusVersion);
indexed = 4;
const fresh = new BsiCorpus({ catalogs: async () => catalogs, ingestion: { ingest } as never, countIndexed: async () => indexed });
expect(await fresh.ensure()).toBe(corpusVersion);
expect(ingest).toHaveBeenCalledTimes(1);
});
it('reports the index for the Data Hub: not built, building with progress, built, outdated, failed, and rebuilds on request', async () => {
let indexed = 0;
let otherVersions = 0;
let release!: () => void;
let fail = false;
const ingest = vi.fn(async (_documents: unknown[], onProgress?: (completed: number, total: number) => void) => {
onProgress?.(2, 4);
await new Promise<void>(resolve => { release = resolve; });
if (fail) throw new Error('Synthetic embedding failure.');
indexed = 4;
return { documentCount: 4, chunkCount: 4, embeddingModel: 'bge-m3' };
});
const corpus = new BsiCorpus({ catalogs: async () => catalogs, ingestion: { ingest } as never,
countIndexed: async () => indexed, countCorpus: async () => indexed + otherVersions });
expect(await corpus.status()).toMatchObject({ state: 'not-built', requirementCount: 4, corpusVersion, indexedChunks: 0 });
otherVersions = 3;
expect((await corpus.status()).state).toBe('outdated');
expect(await corpus.startBuild(false)).toMatchObject({ state: 'building' });
await new Promise(resolve => setTimeout(resolve, 0));
expect(await corpus.status()).toMatchObject({ state: 'building', progress: { completed: 2, total: 4 } });
// A second click while it runs starts nothing new.
await corpus.startBuild(false);
expect(ingest).toHaveBeenCalledTimes(1);
release();
await corpus.ensure();
expect(await corpus.status()).toMatchObject({ state: 'built', indexedChunks: 4 });
expect((await corpus.status()).builtAt).toBeDefined();
// A rebuild embeds again even though the index is complete; a failure is reported until the next build.
fail = true;
await corpus.startBuild(true);
await new Promise(resolve => setTimeout(resolve, 0));
release();
await new Promise(resolve => setTimeout(resolve, 0));
expect(ingest).toHaveBeenCalledTimes(2);
expect(await corpus.status()).toMatchObject({ state: 'failed', error: 'Synthetic embedding failure.' });
expect(await new BsiCorpus({ catalogs: async () => catalogs }).status()).toMatchObject({ state: 'unavailable' });
});
});

View File

@ -0,0 +1,145 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { resetEnginePreferencesForTests } from '../../src/app/engine-preferences';
import type { RegulatoryConsistencyResponse } from '../../src/compliance/bsi-consistency';
import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping';
import { MappingReviewTile } from '../../src/compliance/ui/MappingReviewTile';
import { RegulatoryMappingBatchDialog, useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog';
import { FindingBsiRequirements } from '../../src/compliance/ui/FindingBsiRequirements';
// Phase 13F, BSI stage 2 (user decision 2026-10-09): the ISO <-> BSI review in the Client Info Compliance tab, the
// BSI batch beside the ISO batch in the Result DB and the direct BSI mapping in the Finding Reader. Synthetic data only.
const review: RegulatoryConsistencyResponse = {
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] },
crosswalkVersion: '0.1-synthetic', catalogVersion: '2026-01-01T00:00:00+00:00',
counts: { consistent: 1, inconsistent: 1, noReference: 0, notCompared: 2, reviewed: 0 },
items: [
{ resultProjectionId: 'scanner-finding:dummy-a', title: 'Synthetic check A', sourceTool: 'purple-knight', sourceRuleId: 'SYN-A', findingCount: 2,
state: 'inconsistent', reviewed: false, iso: [{ controlId: '8.9', title: 'Configuration management' }], derivedCount: 2,
direct: [{ requirementId: 'BER.3.5', title: 'Synthetische Anforderung', practice: 'BER', derived: false, reachable: true }],
reverse: [{ controlId: '5.15', title: 'Access control', requirementIds: ['BER.3.5'], decidable: true }] },
{ resultProjectionId: 'scanner-finding:dummy-b', title: 'Synthetic check B', sourceTool: 'purple-knight', findingCount: 1,
state: 'consistent', reviewed: false, iso: [{ controlId: '8.9' }], derivedCount: 2,
direct: [{ requirementId: 'KONF.2.1', derived: true, reachable: true }], reverse: [] }
]
};
const mapping = (framework: 'iso27001' | 'bsi-kernel'): RegulatoryMappingResponse => ({
version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy-a', framework, generationEnabled: true, state: 'succeeded',
run: { candidates: framework === 'iso27001' ? [{ controlId: '5.15', annexAControlId: 'A.5.15', title: 'Access control', score: 0.8, evidence: [] }] : [] } as never,
decisions: framework === 'bsi-kernel' ? [{ version: 1, id: 'dummy-bsi-decision', framework, ruleKey: 'dummy', sourceTool: 'purple-knight',
mappingId: 'dummy-bsi-mapping', controlId: 'BER.3.5', decision: 'accepted', createdAt: '2026-10-09T10:00:00Z' }] : [],
...(framework === 'bsi-kernel' ? { consistency: { state: 'inconsistent' as const, reviewed: false } } : {})
});
let host: HTMLDivElement;
let root: Root;
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('ISO <-> BSI review tile', () => {
it('shows the counts, lists inconsistent rules first, accepts a suggested ISO control and marks a rule reviewed', async () => {
const consistency = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingConsistency').mockResolvedValue(review);
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async (_project, _result, framework = 'iso27001') => mapping(framework));
const decide = vi.spyOn(AssessorApiClient.prototype, 'saveRegulatoryMappingDecision').mockResolvedValue(mapping('iso27001'));
const reviewed = vi.spyOn(AssessorApiClient.prototype, 'markRegulatoryMappingConsistencyReviewed').mockResolvedValue({ reviewed: true });
await act(async () => {
root.render(<MappingReviewTile projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]} backendStatus="connected"/>);
await flush();
});
const facts = [...host.querySelectorAll('[data-framework="iso-bsi-review"] .client-env-facts > div')].map(cell => cell.textContent);
expect(facts).toEqual(['Compared rules2', 'Inconsistent1', 'No ISO reference0', 'Consistent1', 'Not compared2', 'Reviewed0']);
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Open ISO ↔ BSI review results"]')!.click());
const window = () => document.querySelector<HTMLElement>('.mapping-review')!;
// The window starts on the rules that need a review; the other states are one click away.
expect([...window().querySelectorAll('.mapping-review__title')].map(title => title.textContent)).toEqual(['Synthetic check A']);
expect(window().querySelector('.mapping-review__note')?.textContent).toContain('2 rules have an accepted ISO control but no accepted BSI requirement yet.');
await act(async () => { window().querySelector<HTMLElement>('details[data-consistency="inconsistent"] > summary')!.click(); await flush(); });
const detail = () => window().querySelector<HTMLElement>('.mapping-review__detail')!;
expect(detail().textContent).toContain('BER.3.5');
expect(detail().textContent).toContain('not derived from the accepted ISO controls');
// Accepting the ISO control the BSI mapping points to is an ordinary ISO decision.
const accept = [...detail().querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Accept')!;
await act(async () => { accept.click(); await flush(); });
expect(decide).toHaveBeenCalledWith('dummy-project', expect.objectContaining({ resultProjectionId: 'scanner-finding:dummy-a', controlId: '5.15', decision: 'accepted' }));
expect(decide.mock.calls[0][1]).not.toHaveProperty('framework');
// A kept disagreement is recorded with its comment, and the review reloads.
const calls = consistency.mock.calls.length;
await act(async () => {
const input = detail().querySelector<HTMLInputElement>('.mapping-review__actions input')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Kept on purpose.');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => { [...detail().querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Mark reviewed')!.click(); await flush(); });
expect(reviewed).toHaveBeenCalledWith('dummy-project', { resultProjectionId: 'scanner-finding:dummy-a', comment: 'Kept on purpose.' });
expect(consistency.mock.calls.length).toBeGreaterThan(calls);
await act(async () => [...window().querySelectorAll<HTMLButtonElement>('.mapping-review__filters button')].find(button => button.textContent === 'Consistent (1)')!.click());
expect([...window().querySelectorAll('.mapping-review__title')].map(title => title.textContent)).toEqual(['Synthetic check B']);
});
it('shows em dashes while loading, never zero', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingConsistency').mockReturnValue(new Promise(() => undefined));
await act(async () => {
root.render(<MappingReviewTile projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]} backendStatus="connected"/>);
await flush();
});
expect([...host.querySelectorAll('.client-env-facts dd')].map(value => value.textContent)).toEqual(['—', '—', '—', '—', '—', '—']);
});
});
function BatchHarness() {
const controller = useRegulatoryMappingBatch('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: { deviation: true } });
return <RegulatoryMappingBatchDialog controller={controller} filteredResultCount={3} onClose={() => undefined}/>;
}
describe('Result DB mapping batch dialog', () => {
it('offers a BSI batch of the same filters beside the ISO batch', async () => {
const status = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async (projectId, scope) =>
({ version: 1, projectId, scope, generationEnabled: true, state: 'empty' }));
const start = vi.spyOn(AssessorApiClient.prototype, 'startRegulatoryMappingBatch').mockImplementation(async (projectId, request) =>
({ version: 1, projectId, scope: request, generationEnabled: true, state: 'queued' }) as never);
await act(async () => { root.render(<BatchHarness/>); await flush(); });
expect(status.mock.calls.map(call => call[2] ?? 'iso27001')).toEqual(['iso27001', 'bsi-kernel']);
const buttons = () => [...document.querySelectorAll<HTMLButtonElement>('.regulatory-batch-dialog button')].map(button => button.textContent);
expect(buttons()).toEqual(['Start mapping batch', 'Start BSI mapping batch', 'Close']);
await act(async () => { [...document.querySelectorAll<HTMLButtonElement>('.regulatory-batch-dialog button')].find(button => button.textContent === 'Start BSI mapping batch')!.click(); await flush(); });
expect(start).toHaveBeenCalledTimes(1);
expect(start.mock.calls[0][1]).toMatchObject({ framework: 'bsi-kernel', filters: { deviation: true }, assessmentIds: ['dummy-assessment'] });
});
});
describe('Finding Reader direct BSI mapping', () => {
it('shows the directly accepted requirements with their consistency and opens the BSI mapping window', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async (_project, _result, framework = 'iso27001') => mapping(framework));
await act(async () => {
root.render(<FindingBsiRequirements projectId="dummy-project" resultProjectionId="scanner-finding:dummy-a" backendStatus="connected"/>);
await flush();
});
const section = host.querySelector<HTMLDetailsElement>('details[data-bsi-requirements]')!;
expect(section.open).toBe(false);
expect(section.querySelector(':scope > summary')?.textContent).toBe('BSI requirements1 mapped directly · Inconsistent with ISO');
expect([...section.querySelectorAll('[data-direct-requirement]')].map(item => item.getAttribute('data-direct-requirement'))).toEqual(['BER.3.5']);
await act(async () => { [...section.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Map BSI requirements (1)')!.click(); });
expect(document.querySelector('[role="dialog"].regulatory-mapping-dialog')?.textContent).toContain('BSI requirement mapping');
});
});

View File

@ -205,12 +205,26 @@ describe('Client Info ISO controls', () => {
expect(resultsButton()?.textContent).toBe('Results');
await act(async () => lastMapping()!.click());
const rows = [...document.querySelectorAll('.client-information-table tbody tr')].map(row => [...row.children].slice(1).map(cell => cell.textContent));
expect(rows).toEqual([['Automatic', 'This timeslot', 'Completed', '7', '2', '3', '2', '0'],
['Single finding', 'Other timeslot', 'Completed', '—', '1', '0', '1', '0'],
['Manual', 'Other client', 'Failed', '7', '2', '3', '0', '2']]);
expect(rows).toEqual([['ISO 27001/27002', 'Automatic', 'This timeslot', 'Completed', '7', '2', '3', '2', '0'],
['ISO 27001/27002', 'Single finding', 'Other timeslot', 'Completed', '—', '1', '0', '1', '0'],
['ISO 27001/27002', 'Manual', 'Other client', 'Failed', '7', '2', '3', '0', '2']]);
expect(document.querySelector('.client-compliance-history')).not.toBeNull();
});
it('lists direct BSI mappings in the same history and names BSI on the button (user decision 2026-10-09)', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [
{ id: 'dummy-bsi-batch', kind: 'batch', framework: 'bsi-kernel', scope: 'timeslot', trigger: 'manual', status: 'completed', createdAt: '2026-10-09T09:12:00Z', counts },
{ id: 'dummy-bsi-single', kind: 'single', framework: 'bsi-kernel', scope: 'timeslot', trigger: 'manual', status: 'completed', createdAt: '2026-10-09T08:00:00Z',
counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } },
{ id: 'dummy-iso-batch', kind: 'batch', framework: 'iso27001', scope: 'timeslot', trigger: 'automatic', status: 'completed', createdAt: '2026-10-08T09:12:00Z', counts }] });
await render();
expect(lastMapping()?.textContent).toMatch(/^Last mapping: .+ · BSI · Manual$/);
await act(async () => lastMapping()!.click());
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Mapping history');
const rows = [...document.querySelectorAll('.client-compliance-history tbody tr')].map(row => [...row.children].slice(1, 3).map(cell => cell.textContent));
expect(rows).toEqual([['BSI Grundschutz++', 'Manual'], ['BSI Grundschutz++', 'Single finding'], ['ISO 27001/27002', 'Automatic']]);
});
it('names the scope on the button when the latest mapping is not this timeslot', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [
{ id: 'dummy-single', kind: 'single', scope: 'other-client', trigger: 'manual', status: 'running', createdAt: '2026-10-08T08:00:00Z',
@ -400,7 +414,7 @@ describe('Client Info Compliance overview', () => {
expect(document.querySelector('.compliance-footprint--window')).toBeNull();
// The framework tiles stay unchanged, and the switch returns to ISO.
expect([...host.querySelectorAll<HTMLElement>('.client-compliance-grid > section')].map(section => section.dataset.framework))
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus']);
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus', 'iso-bsi-review']);
await act(async () => switchButton('iso')!.click());
expect(host.querySelector<HTMLElement>('.compliance-overview')?.dataset.frameworkView).toBe('iso');
expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93);
@ -479,7 +493,7 @@ describe('Client Info BSI Grundschutz++ tile', () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi });
await render();
expect([...host.querySelectorAll<HTMLElement>('.client-compliance-grid > section.client-env-group')].map(section => section.dataset.framework))
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus']);
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus', 'iso-bsi-review']);
expect(tile()!.querySelector('.client-env-group-title')?.textContent).toBe('BSI Grundschutz++');
// KONF.1.1 reaches all three findings through 8.9; a finding counts once per practice and once in total.
expect(facts()).toEqual([['Requirements', '4'], ['Findings', '3'], ['Konfiguration', '3 findings'], ['Architektur', '2 findings'], ['Berechtigung', '1 finding']]);

View File

@ -18,8 +18,8 @@ describe('Compliance Engine requests in the activity log', () => {
['/projects/p/regulatory-mappings/scanner-finding%3Ao', 'GET', 'Loading ISO mapping'],
['/projects/p/regulatory-mapping-runs', 'POST', 'Generating ISO control proposals'],
['/projects/p/regulatory-mapping-decisions', 'POST', 'Saving ISO mapping decision'],
['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading ISO mapping batch status'],
['/projects/p/regulatory-mapping-batches', 'POST', 'Starting ISO mapping batch'],
['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading compliance mapping batch status'],
['/projects/p/regulatory-mapping-batches', 'POST', 'Starting compliance mapping batch'],
['/reference-data/iso-corpus', 'GET', 'Loading ISO corpus status'],
['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus'],
['/reference-data/bsi-grundschutz', 'GET', 'Loading BSI catalog'],

View File

@ -6,6 +6,9 @@ import { LocalComplianceStore } from '../../src/compliance/local-compliance-stor
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
import type { ScanRun } from '../../src/domain/canonical-model';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { bundledBsiCatalogs } from '../../server/compliance/bsi-snapshots';
import { bsiCorpusVersion } from '../../server/compliance/bsi-corpus';
import { BSI_CORPUS_ID } from '../../src/compliance/regulatory-mapping';
// Permanent on/off invariance of the Compliance Engine (AGENTS.md, "Core Engine and engines"): the Core
// works unchanged with the engine switched off, and running the engine - a single mapping, a decision and
@ -91,6 +94,52 @@ describe('Compliance Engine on/off invariance', () => {
expect(JSON.stringify(await repository.queryUnifiedResults({ audience: 'result-db', projectId: 'dummy-project' }))).toBe(resultDbOff);
});
it('leaves the Core unchanged through a direct BSI mapping, its decisions, the ISO <-> BSI review and a BSI batch', async () => {
const repository = await syntheticRepository();
const off = { revision: repository.revision, data: JSON.stringify(await repository.exportProject('dummy-project')) };
const resultDbOff = JSON.stringify(await repository.queryUnifiedResults({ audience: 'result-db', projectId: 'dummy-project' }));
const requirementId = bundledBsiCatalogs.catalog.requirements[0].id;
const corpusVersion = bsiCorpusVersion(bundledBsiCatalogs.catalog);
const bsiChunk: RegulatoryChunk = { schemaVersion: 1, corpusId: BSI_CORPUS_ID, corpusVersion, documentId: `bsi-kernel:${requirementId}`,
framework: 'BSI Grundschutz++', frameworkVersion: bundledBsiCatalogs.catalog.version, controlId: requirementId, chunkId: 'bsi-chunk', chunkIndex: 0,
language: 'de', title: 'Synthetic requirement', text: 'Synthetic requirement text.', textSha256: 'b'.repeat(64) };
const isBsi = (filters?: { corpusId?: string }) => filters?.corpusId === BSI_CORPUS_ID;
const store = new LocalComplianceStore();
const service = new RegulatoryMappingService(complianceReadPortFrom(repository), store, {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (request: RegulatoryRetrievalRequest) =>
[{ score: 0.9, chunk: isBsi(request.filters) ? bsiChunk : chunk('ISO/IEC 27002') }]) }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(),
list: vi.fn(async (filters: { corpusId?: string }) => [isBsi(filters) ? bsiChunk : chunk('ISO/IEC 27001')]) } as never,
client: { model: 'work-model', complete: vi.fn(async (system: string) => ({ value: system.includes('BSI Grundschutz++')
? { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: requirementId, relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: ['bsi-chunk'] }] }
: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } })) } as never,
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp),
bsiCorpus: { configured: true, ensure: vi.fn(async () => corpusVersion) } as never
});
const resultProjectionId = 'scanner-finding:dummy-occurrence';
await service.start('dummy-project', { resultProjectionId });
await service.start('dummy-project', { resultProjectionId, framework: 'bsi-kernel' });
await service.close();
const automatic = (await service.get('dummy-project', resultProjectionId, 'bsi-kernel')).decisions[0];
await service.decide('dummy-project', { resultProjectionId, framework: 'bsi-kernel', controlId: requirementId, decision: 'accepted',
comment: 'Confirmed.', expectedLatestDecisionId: automatic.id });
const review = await service.consistency('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
expect(review.items).toHaveLength(1);
await service.markConsistencyReviewed('dummy-project', { resultProjectionId });
await service.startBatch('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {}, idempotencyKey: 'dummy-bsi-batch', framework: 'bsi-kernel' });
await service.close();
const engine = await store.exportProject('dummy-project');
expect(engine.regulatoryMappingRuns.map(run => run.framework ?? 'iso27001').sort()).toEqual(['bsi-kernel', 'iso27001']);
expect(engine.regulatoryConsistencyReviews).toHaveLength(1);
expect(engine.regulatoryMappingBatchRuns.map(batch => [batch.framework, batch.status])).toEqual([['bsi-kernel', 'completed']]);
expect(repository.revision).toBe(off.revision);
expect(JSON.stringify(await repository.exportProject('dummy-project'))).toBe(off.data);
expect(JSON.stringify(await repository.queryUnifiedResults({ audience: 'result-db', projectId: 'dummy-project' }))).toBe(resultDbOff);
});
it('gives the engine no Core write method', () => {
const port = complianceReadPortFrom(new LocalAssessorRepository());
expect(Object.keys(port).sort()).toEqual(['getProject', 'getUnifiedResultById', 'listAssessments', 'listProjectScanRuns',

View File

@ -76,6 +76,9 @@ afterEach(async () => {
resetEnginePreferencesForTests();
});
// The Finding Reader also loads the direct BSI mapping of the finding (Phase 13F, stage 2); these counts are ISO requests.
const isoCalls = (request: { mock: { calls: unknown[][] } }) => request.mock.calls.filter(call => (call[2] ?? 'iso27001') === 'iso27001').length;
describe('Compliance Engine switch', () => {
it('removes the Finding Reader ISO window, section and requests while off and reopens closed', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue(mappingResponse);
@ -88,7 +91,7 @@ describe('Compliance Engine switch', () => {
// Switched on, the mapping loads with the finding; the button opens it as a window like Accepted Risk.
await act(async () => { setEngineEnabled('compliance', true); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(1);
expect(isoCalls(request)).toBe(1);
expect(isoWindow()).toBeNull();
await act(async () => { isoButton()!.click(); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(isoWindow()?.querySelector('.regulatory-mapping-panel')).not.toBeNull();
@ -103,7 +106,7 @@ describe('Compliance Engine switch', () => {
await act(async () => { setEngineEnabled('compliance', true); });
expect(isoButton()?.getAttribute('aria-expanded')).toBe('false');
expect(isoWindow()).toBeNull();
expect(request).toHaveBeenCalledTimes(1);
expect(isoCalls(request)).toBe(1);
});
it('lists the controls accepted for the finding as a reader section like Description', async () => {
@ -127,14 +130,15 @@ describe('Compliance Engine switch', () => {
mappingId: 'dummy-mapping', controlId, decision: 'accepted' as const, createdAt: '2026-10-05T10:00:00Z' });
const requirement = (requirementId: string, title: string, isoControlIds: string[]) => ({ requirementId, title, practice: requirementId.split('.')[0],
statement: `Synthetische Anforderung ${requirementId}.`, via: isoControlIds.map(isoControlId => ({ isoControlId, relationship: 'subset-of' as const })) });
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue({ ...mappingResponse, state: 'succeeded',
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async (_project, _result, framework) => framework === 'bsi-kernel'
? { ...mappingResponse, framework } : { ...mappingResponse, state: 'succeeded',
run: { candidates: [{ controlId: '8.2', annexAControlId: 'A.8.2', score: 0.9, evidence: [] },
{ controlId: '5.18', annexAControlId: 'A.5.18', score: 0.8, evidence: [] }] } as never,
decisions: [accepted('8.2'), accepted('5.18')],
// Synthetic derived requirements; the real ones come from the active BSI mapping on the server.
bsi: { catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution (CC BY-SA 4.0).',
practices: [{ id: 'BER', title: 'Berechtigung' }], missingTargets: [],
requirements: [requirement('BER.3.5', 'Synthetisches Konto', ['8.2']), requirement('BER.5.1', 'Synthetische Ausgabe', ['5.18', '8.2'])] } });
requirements: [requirement('BER.3.5', 'Synthetisches Konto', ['8.2']), requirement('BER.5.1', 'Synthetische Ausgabe', ['5.18', '8.2'])] } } as never);
await renderReader();
const section = () => host.querySelector<HTMLDetailsElement>('details[data-bsi-requirements]');
expect(host.querySelector('[data-iso-controls]')!.nextElementSibling).toBe(section());
@ -145,9 +149,9 @@ describe('Compliance Engine switch', () => {
expect(section()!.open).toBe(true);
// One group per ISO control in numeric order; a requirement reached twice is listed under each of its controls.
const groups = [...section()!.querySelectorAll<HTMLElement>('.finding-bsi-requirements__group')];
expect(groups.map(group => group.querySelector('.finding-bsi-requirements__route')?.textContent)).toEqual(['A.5.18 · subset-of', 'A.8.2 · subset-of']);
expect(groups.map(group => group.querySelector('.finding-bsi-requirements__route')?.textContent)).toEqual(['A.5.18 · subset-of', 'A.8.2 · subset-of', 'Mapped directly']);
expect(groups.map(group => [...group.querySelectorAll('li > details > summary')].map(row => row.textContent)))
.toEqual([['BER.5.1Synthetische Ausgabe'], ['BER.3.5Synthetisches Konto', 'BER.5.1Synthetische Ausgabe']]);
.toEqual([['BER.5.1Synthetische Ausgabe'], ['BER.3.5Synthetisches Konto', 'BER.5.1Synthetische Ausgabe'], []]);
// A statement opens only with its requirement.
const item = groups[1].querySelector<HTMLDetailsElement>('li[data-requirement="BER.3.5"] > details')!;
expect(item.open).toBe(false);
@ -159,7 +163,7 @@ describe('Compliance Engine switch', () => {
// Another finding starts collapsed again.
await renderReader('dummy-other-occurrence');
expect(section()?.open).toBe(false);
expect(request).toHaveBeenCalledTimes(2);
expect(isoCalls(request)).toBe(2);
await act(async () => setEngineEnabled('compliance', false));
expect(section()).toBeNull();
});
@ -175,7 +179,7 @@ describe('Compliance Engine switch', () => {
expect(request).not.toHaveBeenCalled();
await act(async () => { setEngineEnabled('compliance', true); });
expect(host.textContent).toBe('available');
expect(request).toHaveBeenCalledTimes(1);
expect(isoCalls(request)).toBe(1);
const source = readFileSync(join(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8');
expect(source).toContain('{regulatoryBatch.available && <button');

View File

@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import { RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service';
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import type { ComplianceReadPort } from '../../src/compliance/compliance-store';
import { REGULATORY_MAPPING_PROMPT_VERSION } from '../../src/compliance/regulatory-mapping';
import type { RegulatoryChunk, RegulatoryRetrievalFilters } from '../../src/compliance/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
// Prompt 1.3.0 (user decision 2026-10-09): the model reads each ISO candidate's full ISO/IEC 27002 control text in
// document order, not only the passages retrieval matched. Synthetic corpus text only.
const stamp = '2026-10-09T10:00:00.000Z';
type Section = NonNullable<RegulatoryChunk['sectionKind']>;
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002', section: Section, index: number, length = 100): RegulatoryChunk {
const reference = sourceStandard === 'ISO/IEC 27001';
const documentId = reference ? '27001-8.2' : `27002-8.2-${section}`;
return { schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId, framework: 'ISO/IEC 27002', frameworkVersion: '2022',
controlId: '8.2', annexAControlId: 'A.8.2', controlTheme: 'technological', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
sectionKind: section, sourcePageStart: 1, sourcePageEnd: 1, retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test',
sourceSha256: '1'.repeat(64), chunkId: `${documentId}:${index}`, chunkIndex: index, language: 'de', title: 'Synthetic privileged access',
text: `${section} ${index} `.padEnd(length, 'x'), textSha256: 'a'.repeat(64) };
}
const finding: UnifiedResultProjection = {
resultProjectionId: 'scanner-finding:dummy', sourceKind: 'scanner-finding', sourceResultId: 'dummy', projectId: 'project-1', assessmentId: 'assessment-1',
collectionRunId: 'run-1', scanRunId: 'run-1', sourceTool: 'purple-knight', sourceRuleId: 'SYN-1', title: 'Synthetic privileged access check',
description: 'A synthetic privileged access setting is weaker than recommended.', severityOriginal: 'high', severityNormalized: 'high',
outcomeOriginal: 'fail', outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'compliance_check', isIoe: false, isDeviation: true,
lifecycleState: 'new', mappingConfidence: 1, reviewStatus: 'unreviewed', importedAt: stamp, createdAt: stamp, frameworkReferences: [], categories: []
};
const read: ComplianceReadPort = {
getProject: async () => ({ id: 'project-1', name: 'Synthetic' }) as never, listProjects: async () => [] as never,
listAssessments: async () => [] as never, listProjectScanRuns: async () => [] as never,
queryUnifiedResults: async () => ({ total: 0, items: [] }) as never, getUnifiedResultById: async () => finding
};
async function promptFor(corpus: RegulatoryChunk[], matched: RegulatoryChunk[]) {
const complete = vi.fn(async (_system: string, input: string) => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '8.2',
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: [JSON.parse(input).candidates[0].evidence[0].chunkId] }] } }));
const list = vi.fn(async (filters: RegulatoryRetrievalFilters) => corpus.filter(item => item.sourceStandard === filters.sourceStandard));
const svc = new RegulatoryMappingService(read, new LocalComplianceStore(), {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async () => matched.map(item => ({ score: 0.9, chunk: item }))) }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list } as never,
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp)
});
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId });
await svc.close();
const response = await svc.get('project-1', finding.resultProjectionId);
return { prompt: JSON.parse(complete.mock.calls[0][1]), system: complete.mock.calls[0][0], response };
}
describe('ISO full control text (prompt 1.3.0)', () => {
it('sends the 27001 statement and the whole 27002 control, purpose and guidance in document order, not only the matched passage', async () => {
const guidance = [chunk('ISO/IEC 27002', 'guidance', 0), chunk('ISO/IEC 27002', 'guidance', 1), chunk('ISO/IEC 27002', 'guidance', 2)];
const corpus = [chunk('ISO/IEC 27001', 'control', 0), ...guidance, chunk('ISO/IEC 27002', 'purpose', 0), chunk('ISO/IEC 27002', 'control', 0),
chunk('ISO/IEC 27002', 'other-information', 0), chunk('ISO/IEC 27002', 'attributes', 0)];
const { prompt, system, response } = await promptFor(corpus, [guidance[2]]);
expect(REGULATORY_MAPPING_PROMPT_VERSION).toBe('1.3.0');
expect(system).toContain('read the whole text before judging');
const evidence = prompt.candidates[0].evidence as Array<{ chunkId: string; sourceStandard: string }>;
// Attributes are tags, not normative text, and are only sent when retrieval matched them.
expect(evidence.map(item => item.chunkId)).toEqual(['27001-8.2:0', '27002-8.2-control:0', '27002-8.2-purpose:0',
'27002-8.2-guidance:0', '27002-8.2-guidance:1', '27002-8.2-guidance:2', '27002-8.2-other-information:0']);
expect(response).toMatchObject({ state: 'succeeded', run: { promptVersion: '1.3.0' } });
// The browser still receives citations only, never the licensed text.
expect(JSON.stringify(response)).not.toContain('guidance 1 ');
});
it('caps a long control, keeps every matched passage and adds other information only while it fits', async () => {
const guidance = Array.from({ length: 12 }, (_, index) => chunk('ISO/IEC 27002', 'guidance', index, 1_500));
const corpus = [chunk('ISO/IEC 27001', 'control', 0), chunk('ISO/IEC 27002', 'control', 0), ...guidance, chunk('ISO/IEC 27002', 'other-information', 0, 1_500)];
const { prompt } = await promptFor(corpus, [guidance[11]]);
const evidence = (prompt.candidates[0].evidence as Array<{ chunkId: string; sourceStandard: string; text: string }>)
.filter(item => item.sourceStandard === 'ISO/IEC 27002');
const ids = evidence.map(item => item.chunkId);
expect(ids).toContain('27002-8.2-guidance:11');
expect(ids).not.toContain('27002-8.2-other-information:0');
expect(evidence.reduce((total, item) => total + item.text.length, 0)).toBeLessThanOrEqual(8_000);
// Document order is kept even for the matched passage that was added first.
expect(ids.slice(0, 2)).toEqual(['27002-8.2-control:0', '27002-8.2-guidance:0']);
expect(ids.at(-1)).toBe('27002-8.2-guidance:11');
});
});

View File

@ -71,6 +71,14 @@ describe('regulatory finding retrieval boundary', () => {
expect(generic?.publicRuleId).toBeUndefined();
});
it('names Purple Knight by the scoped tool the Result DB delivers and keeps its public indicator ID', () => {
const query = buildRegulatoryFindingQuery(finding({ sourceTool: 'purple-knight-ad', sourceRuleId: 'SI000077' }));
expect(query).toMatchObject({ specificity: 'rule', publicTool: 'Semperis Purple Knight for Active Directory', publicRuleId: 'SI000077' });
expect(query?.query).toContain('Public rule ID: SI000077.');
expect(buildRegulatoryFindingQuery(finding({ sourceTool: 'purple-knight-entra', sourceRuleId: 'SI-custom' })))
.toMatchObject({ specificity: 'topic', publicTool: 'Semperis Purple Knight for Entra ID' });
});
it('maps a GPO baseline comparison from its disclosed setting text and never from the customer value', () => {
const query = buildRegulatoryFindingQuery(finding({
resultProjectionId: 'policy-evaluation-comparison:synthetic', sourceKind: 'policy-evaluation', sourceTool: 'GPOs',

View File

@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest';
import { RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service';
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
import { fuseControlRankings, lexicalTokens, RegulatoryLexicalIndex } from '../../server/compliance/regulatory-lexical-index';
import { bsiCorpusVersion } from '../../server/compliance/bsi-corpus';
import type { BsiCatalogs } from '../../server/compliance/bsi-snapshots';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import type { ComplianceReadPort } from '../../src/compliance/compliance-store';
import { BSI_CORPUS_ID, REGULATORY_RETRIEVAL_VERSION } from '../../src/compliance/regulatory-mapping';
import type { RegulatoryChunk, RegulatoryRetrievalFilters, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
// Hybrid search (user decision 2026-10-09): dense English and German queries plus German keywords, fused by rank;
// BSI leaves out procurement, service-provider, building and personnel requirements. Synthetic corpus only.
const stamp = '2026-10-09T12:00:00.000Z';
function isoChunk(controlId: string, text: string, sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002' = 'ISO/IEC 27002'): RegulatoryChunk {
const reference = sourceStandard === 'ISO/IEC 27001';
const documentId = `${reference ? '27001' : '27002'}-${controlId}-guidance`;
return { schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId, framework: 'ISO/IEC 27002', frameworkVersion: '2022',
controlId, annexAControlId: `A.${controlId}`, controlTheme: 'technological', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
sectionKind: reference ? 'control' : 'guidance', sourcePageStart: 1, sourcePageEnd: 1, retrievalRole: reference ? 'reference' : 'semantic',
parserVersion: 'test', sourceSha256: '1'.repeat(64), chunkId: `${documentId}:0`, chunkIndex: 0, language: 'de', title: `Synthetic ${controlId}`,
text, textSha256: 'a'.repeat(64) };
}
describe('keyword index and rank fusion', () => {
it('stems German and English words so inflected forms meet', () => {
expect(lexicalTokens('Berechtigungen der Dienste')).toEqual(lexicalTokens('Berechtigung Dienst'));
expect(lexicalTokens('services')).toEqual(lexicalTokens('service'));
expect(lexicalTokens('die und the RC4')).toEqual(['rc4']);
});
it('ranks by BM25 and fuses lists at control level deterministically', () => {
const chunks = [isoChunk('8.9', 'Nicht benötigte Dienste und Funktionen deaktivieren.'), isoChunk('5.1', 'Richtlinien für Informationssicherheit festlegen.'),
isoChunk('8.24', 'Kryptographische Verfahren und Algorithmen festlegen.')];
const index = new RegulatoryLexicalIndex(chunks);
expect(index.search('Dienste deaktivieren', 10).map(hit => hit.chunk.controlId)).toEqual(['8.9']);
const dense = [{ score: 0.9, chunk: chunks[1] }, { score: 0.8, chunk: chunks[0] }];
const lexical = [{ score: 3, chunk: chunks[0] }, { score: 1, chunk: chunks[2] }];
// 8.9 is second in one list and first in the other; it overtakes 5.1, which only one list returned.
expect(fuseControlRankings([dense, lexical]).map(control => control.controlId)).toEqual(['8.9', '5.1', '8.24']);
expect(fuseControlRankings([lexical, dense]).map(control => control.controlId)).toEqual(['8.9', '5.1', '8.24']);
expect(fuseControlRankings([dense, lexical], id => id === '8.9').map(control => control.controlId)).toEqual(['5.1', '8.24']);
});
});
const finding: UnifiedResultProjection = {
resultProjectionId: 'scanner-finding:dummy', sourceKind: 'scanner-finding', sourceResultId: 'dummy', projectId: 'project-1', assessmentId: 'assessment-1',
collectionRunId: 'run-1', scanRunId: 'run-1', sourceTool: 'purple-knight-ad', sourceRuleId: 'SI000077', title: 'Synthetic print service enabled on a domain controller',
description: 'A synthetic service that is not needed runs on a domain controller.', severityOriginal: 'high', severityNormalized: 'high',
outcomeOriginal: 'fail', outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'compliance_check', isIoe: false, isDeviation: true,
lifecycleState: 'new', mappingConfidence: 1, reviewStatus: 'unreviewed', importedAt: stamp, createdAt: stamp, frameworkReferences: [], categories: []
};
const read: ComplianceReadPort = {
getProject: async () => ({ id: 'project-1', name: 'Synthetic' }) as never, listProjects: async () => [] as never,
listAssessments: async () => [] as never, listProjectScanRuns: async () => [] as never,
queryUnifiedResults: async () => ({ total: 0, items: [] }) as never, getUnifiedResultById: async () => finding
};
const requirement = (id: string, practice: string) => ({ id, title: `Synthetische Anforderung ${id}`, practice, statement: `Synthetische Aussage ${id}.` });
const catalogs: BsiCatalogs = {
catalog: { schemaVersion: 1, framework: 'bsi-grundschutz-plus-plus', catalogClass: 'BSI-Stand-der-Technik-Kernel-G0', title: 'Synthetic',
version: '2026-01-01T00:00:00+00:00', sourceUrl: 'https://example.invalid/catalog', sourceSha256: 'c'.repeat(64), license: 'Synthetic',
practices: [{ id: 'KONF', title: 'Konfiguration' }, { id: 'DLS', title: 'Dienstleister' }],
requirements: [requirement('KONF.2.4', 'KONF'), requirement('KONF.2.1', 'KONF'), requirement('DLS.3.2', 'DLS')] },
crosswalk: { schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: 'Synthetic', version: '0.1', sourceUrl: 'https://example.invalid/m',
sourceSha256: 'd'.repeat(64), license: 'Synthetic', targetCatalog: 'synthetic', entries: [] }
};
const bsiVersion = bsiCorpusVersion(catalogs.catalog);
const bsiChunk = (controlId: string, text: string): RegulatoryChunk => ({ schemaVersion: 1, corpusId: BSI_CORPUS_ID, corpusVersion: bsiVersion,
documentId: `bsi-kernel:${controlId}`, framework: 'BSI Grundschutz++', frameworkVersion: catalogs.catalog.version, controlId, chunkId: `bsi-kernel:${controlId}:0`,
chunkIndex: 0, language: 'de', title: `Synthetische Anforderung ${controlId}`, text, textSha256: 'b'.repeat(64) });
function setup(expansion: () => Promise<unknown>) {
const isoCorpus = [isoChunk('5.1', 'Richtlinien festlegen.'), isoChunk('8.2', 'Privilegierte Zugangsrechte einschränken.'),
isoChunk('8.9', 'Nicht benötigte Dienste deaktivieren.')];
const bsiCorpus = [bsiChunk('DLS.3.2', 'Dienstleister überprüfen.'), bsiChunk('KONF.2.1', 'Grundkonfiguration dokumentieren.'),
bsiChunk('KONF.2.4', 'Nicht benötigte Systemfunktionen und Dienste deaktivieren.')];
const isBsi = (filters?: RegulatoryRetrievalFilters) => filters?.corpusId === BSI_CORPUS_ID;
// English query: 5.1 / DLS.3.2 only. German query: 8.2 / KONF.2.1. Keywords find 8.9 / KONF.2.4.
const retrieve = vi.fn(async (request: RegulatoryRetrievalRequest) => {
const german = request.query.startsWith('Deaktivierung');
const pick = isBsi(request.filters) ? (german ? bsiCorpus[1] : bsiCorpus[0]) : (german ? isoCorpus[1] : isoCorpus[0]);
return [{ score: 0.9, chunk: pick }];
});
const listAll = vi.fn(async (filters: RegulatoryRetrievalFilters) => isBsi(filters) ? bsiCorpus : isoCorpus);
const list = vi.fn(async (filters: RegulatoryRetrievalFilters) => isBsi(filters)
? bsiCorpus.filter(item => item.controlId === filters.controlId)
: filters.sourceStandard === 'ISO/IEC 27001' ? [isoChunk(filters.controlId!, 'Synthetic statement.', 'ISO/IEC 27001')]
: isoCorpus.filter(item => item.controlId === filters.controlId));
const complete = vi.fn(async (_system: string, input: string) => {
const candidates = JSON.parse(input).candidates as Array<{ controlId: string; evidence: Array<{ chunkId: string }> }>;
return { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: candidates[0].controlId, relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: [candidates[0].evidence.at(-1)!.chunkId] }] } };
});
const expand = vi.fn(async () => ({ value: await expansion() }));
const svc = new RegulatoryMappingService(read, new LocalComplianceStore(), {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list, listAll } as never,
client: { model: 'work-model', complete: complete as never }, queryExpansionClient: { model: 'work-model', complete: expand as never },
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp),
bsiCatalogs: async () => catalogs, bsiCorpus: { configured: true, ensure: vi.fn(async () => bsiVersion) } as never
});
return { svc, expand, retrieve, listAll };
}
const germanRestatement = { query: 'Deaktivierung nicht benötigter Dienste auf Domänencontrollern', terms: ['Dienste deaktivieren', 'Systemfunktionen', 'Härtung'] };
describe('hybrid search in the mapping pipeline', () => {
it('fuses the English, German and keyword lists, records the restatement and shares it between ISO and BSI', async () => {
const { svc, expand, listAll } = setup(async () => germanRestatement);
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId });
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId, framework: 'bsi-kernel' });
await svc.close();
const iso = (await svc.get('project-1', finding.resultProjectionId)).run!;
const bsi = (await svc.get('project-1', finding.resultProjectionId, 'bsi-kernel')).run!;
expect(iso.retrievalVersion).toBe(REGULATORY_RETRIEVAL_VERSION);
// Each list contributes its control; the keyword search finds the control the dense searches miss.
expect(iso.candidates.map(candidate => candidate.controlId).sort()).toEqual(['5.1', '8.2', '8.9']);
expect(iso.preparedQuery.expansion).toMatchObject({ language: 'de', query: germanRestatement.query, searches: ['dense-en', 'dense-de', 'keyword-de'] });
// DLS (service providers) is left out for a technical finding even though the English search ranked it first.
expect(bsi.candidates.map(candidate => candidate.controlId).sort()).toEqual(['KONF.2.1', 'KONF.2.4']);
expect(expand).toHaveBeenCalledTimes(1);
expect(listAll).toHaveBeenCalledTimes(2);
});
it('falls back to the English dense search when the restatement fails, and retries it for the next run', async () => {
let calls = 0;
const { svc, expand } = setup(async () => (calls++ === 0 ? { unexpected: true } : germanRestatement));
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId });
await svc.close();
const first = (await svc.get('project-1', finding.resultProjectionId)).run!;
expect(first.status).toBe('succeeded');
expect(first.preparedQuery.expansion).toBeUndefined();
expect(first.candidates.map(candidate => candidate.controlId)).toEqual(['5.1']);
await svc.start('project-1', { resultProjectionId: finding.resultProjectionId, framework: 'bsi-kernel' });
await svc.close();
expect(expand).toHaveBeenCalledTimes(2);
expect((await svc.get('project-1', finding.resultProjectionId, 'bsi-kernel')).run!.preparedQuery.expansion?.query).toBe(germanRestatement.query);
});
});

View File

@ -11,8 +11,8 @@ import { RegulatoryMappingBatchDialog } from '../../src/compliance/ui/Regulatory
describe('Result DB regulatory mapping batch', () => {
it('places the action with the Result DB page actions and shares its scoped query with export', () => {
const source = readFileSync(resolve(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8');
expect(source.indexOf('Map all ISO controls')).toBeGreaterThan(source.indexOf("'Refresh'"));
expect(source.indexOf('Map all ISO controls')).toBeLessThan(source.indexOf("'Exporting …'"));
expect(source.indexOf('Compliance mapping')).toBeGreaterThan(source.indexOf("'Refresh'"));
expect(source.indexOf('Compliance mapping')).toBeLessThan(source.indexOf("'Exporting …'"));
expect(source).toContain('buildScopedResultDbQuery');
expect(source).toContain('queryAllUnifiedResults');
expect(source).toContain('resultDbBatchFilters(scopedQuery)');

View File

@ -391,8 +391,8 @@ describe('regulatory mapping service', () => {
expect(single).toMatchObject({ status: 'completed', counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } });
expect(single.counts.findings).toBeUndefined();
// Content-free: no client, rule, scope id, filter or key leaves the engine with an entry.
expect(Object.keys(single).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
expect(Object.keys(automatic).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
expect(Object.keys(single).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'framework', 'id', 'kind', 'scope', 'status', 'trigger']);
expect(Object.keys(automatic).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'framework', 'id', 'kind', 'scope', 'status', 'trigger']);
// The stored origin is hashed: runs are global and exported with every client, so no id is kept.
const stored = JSON.stringify(await repository.listRegulatoryMappingRuns());
for (const id of ['project-1', 'project-2', 'assessment-1', 'assessment-2', 'assessment-3', 'run-1', 'run-2', 'run-3']) {
@ -468,11 +468,14 @@ describe('regulatory mapping service', () => {
expect(complianceWorkFacetIndex(overview).facets[0].valuesFor!(workSetting)).toEqual(['5.1']);
});
it('accepts the first two proposals automatically, keeps existing decisions and accepts nothing without a proposal', async () => {
it('accepts the first two strong proposals automatically, keeps existing decisions and accepts nothing without one', async () => {
const semantic = (controlId: string): RegulatoryChunk => ({ ...chunk('ISO/IEC 27002'), controlId, annexAControlId: `A.${controlId}`,
documentId: `27002-${controlId}`, chunkId: `semantic-${controlId}` });
const proposal = (controlId: string) => ({ controlId, relationship: 'supporting', confidence: 'medium', rationale: 'Synthetic rationale.',
evidenceChunkIds: [`semantic-${controlId}`] });
// User decision 2026-10-09: only primary proposals with high or medium confidence are strong.
const proposal = (spec: string) => {
const [controlId, relationship = 'primary', confidence = 'high'] = spec.split('/');
return { controlId, relationship, confidence, rationale: 'Synthetic rationale.', evidenceChunkIds: [`semantic-${controlId}`] };
};
const build = (repository: EngineRepository, value: unknown) => new RegulatoryMappingService(repository, repository, {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) =>
['8.20', '5.15', '5.1'].map((controlId, index) => ({ score: 0.9 - index / 10, chunk: semantic(controlId) }))) }),
@ -483,8 +486,8 @@ describe('regulatory mapping service', () => {
const accepted = async (svc: RegulatoryMappingService) => acceptedControlIds((await svc.get('project-1', 'scanner-finding:occurrence-1')).decisions).sort();
const mapped = (...controls: string[]) => ({ schemaVersion: 1, outcome: 'mapped', mappings: controls.map(proposal) });
// Three proposals: the first two in the model's order are accepted, the third stays undecided.
const three = build(mockRepository([finding()]), mapped('5.15', '8.20', '5.1'));
// Three strong proposals: the first two in the model's order are accepted, the third stays undecided.
const three = build(mockRepository([finding()]), mapped('5.15', '8.20/primary/medium', '5.1'));
await three.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
await three.close();
expect(await accepted(three)).toEqual(['5.15', '8.20']);
@ -503,6 +506,16 @@ describe('regulatory mapping service', () => {
await three.close();
expect(await accepted(three)).toEqual(['8.20']);
// Weak proposals are skipped, not counted: a supporting or low-confidence proposal waits for a person.
const mixed = build(mockRepository([finding()]), mapped('5.15/supporting/high', '8.20/primary/low', '5.1/primary/medium'));
await mixed.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
await mixed.close();
expect(await accepted(mixed)).toEqual(['5.1']);
const weak = build(mockRepository([finding()]), mapped('5.15/supporting/medium', '8.20/primary/low'));
await weak.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
await weak.close();
expect((await weak.get('project-1', 'scanner-finding:occurrence-1'))).toMatchObject({ state: 'succeeded', decisions: [] });
// Without a proposal nothing is accepted.
const none = build(mockRepository([finding()]), { schemaVersion: 1, outcome: 'no_relevant_control', mappings: [], reason: 'Synthetic refusal.' });
await none.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { databaseMigrations } from '../../server/postgres/migration-catalog';
describe('PostgreSQL regulatory mapping framework migration', () => {
it('adds a framework to mappings and decisions and a global consistency review table', () => {
const migration = databaseMigrations.find(item => item.version === '029');
expect(migration).toMatchObject({ version: '029', name: 'regulatory-mapping-framework' });
const sql = migration!.sql;
// Every existing row is an ISO mapping; only the two known frameworks are allowed.
expect(sql.match(/ADD COLUMN framework text NOT NULL DEFAULT 'iso27001' CHECK \(framework IN \('iso27001', 'bsi-kernel'\)\)/g)).toHaveLength(2);
expect(sql).toContain('UNIQUE (framework, rule_key, corpus_version, retrieval_version, prompt_version, model)');
expect(sql).toMatch(/assessor_regulatory_mapping_decisions_rule_control_idx[\s\S]*\(rule_key, framework, control_id/);
expect(sql).toContain('CREATE TABLE assessor_regulatory_consistency_reviews');
expect(sql).toContain("fingerprint text NOT NULL CHECK (fingerprint ~ '^[a-f0-9]{64}$')");
// Reviews are global per rule like decisions, and the migration never drops data.
expect(sql).not.toContain('project_id');
expect(sql).not.toMatch(/DROP TABLE|DELETE FROM|TRUNCATE/);
});
});

View File

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