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:
parent
bc9a439b91
commit
6a784a7626
|
|
@ -3434,9 +3434,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.
|
||||
|
|
@ -3588,6 +3632,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. |
|
||||
|
|
@ -4533,6 +4578,13 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
|
|||
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
|
||||
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
|
||||
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
|
||||
| 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. |
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -189,6 +192,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;
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +339,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,
|
||||
|
|
@ -797,6 +807,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);
|
||||
|
|
@ -1253,7 +1272,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.');
|
||||
|
|
@ -1275,7 +1294,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.');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)] },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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(' | ||||