2.22.0: BSI Grundschutz++ derived from ISO mappings
Compliance Engine stage 1 for BSI Grundschutz++ (Kernel requirements only): - Pinned snapshots of the BSI Kernel catalog and the official ISO 27001 Annex A -> Grundschutz++ mapping with source URL, SHA-256 and CC BY-SA attribution, generated by server/compliance/update-bsi-assets.mjs. - Requirements are derived from accepted ISO controls, never stored as decisions; missing mapping targets are reported. - Client Info Compliance tab: BSI Grundschutz++ tile and Results window. - Finding Reader: collapsed BSI requirements section grouped by ISO control. - Work: BSI requirement and BSI practice facets. - Data Hub: BSI catalog tile with Update from BSI and Use bundled catalog; new outbound target documented in SECURITY.md.
This commit is contained in:
parent
8ac7050e29
commit
5e7e787dbb
25
SECURITY.md
25
SECURITY.md
|
|
@ -28,6 +28,31 @@ bleibt ableitbar; PostgreSQL-Findings und Review-Entscheidungen werden durch
|
|||
den Upload nicht veraendert. Phase-14-Authentifizierung und Rollen bleiben
|
||||
offen.
|
||||
|
||||
## BSI-Katalog: Update from BSI (Phase 13F)
|
||||
|
||||
Nutzerfreigabe 2026-10-08: Die Data-Hub-Aktion `Update from BSI` ist ein neues,
|
||||
eng begrenztes ausgehendes Ziel. Nur das Backend ruft genau zwei feste
|
||||
oeffentliche Dateien der BSI-Stand-der-Technik-Bibliothek ab
|
||||
(`https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/main/...`:
|
||||
aufgeloester Grundschutz++-Katalog und Zuordnung ISO 27001 Annex A ->
|
||||
Grundschutz++), ausschliesslich ueber HTTPS, ohne Weiterleitung, mit 120 s
|
||||
Zeitlimit und 32 MiB je Datei. Es werden keine Projekt-, Finding- oder
|
||||
Kundendaten gesendet; die Anfrage enthaelt nur die feste URL. Der Browser
|
||||
bleibt Same-Origin und erreicht GitHub nicht selbst.
|
||||
|
||||
Beide Dateien sind nicht vertrauenswuerdige Eingabe: Sie werden nur als JSON
|
||||
gelesen und vom Konverter fail-closed geprueft (Version, IDs, Parameter,
|
||||
Beziehungen); ein ungueltiger oder unvollstaendiger Stand wird abgelehnt, und
|
||||
der aktive Katalog bleibt unveraendert. Gespeichert wird nur der kompakte
|
||||
Kernel-Katalog samt Zuordnung mit Quell-URL und SHA-256 atomar in
|
||||
`<artifact root>/regulatory-corpora/bsi/active-catalog.json` (Dateimodus 0600,
|
||||
privates Verzeichnis, kein Symlink). Ein Stand, der aelter als der aktive oder
|
||||
der mitgelieferte ist, wird nie aktiviert; `Use bundled catalog` loescht nur
|
||||
diese Datei. Keine PostgreSQL-Tabelle, kein Finding und keine ISO-Entscheidung
|
||||
wird veraendert; abgeleitete BSI-Anforderungen folgen dem aktiven Katalog. Wie
|
||||
`Update from MITRE` ist die Aktion bis zur Phase-14-Nutzerverwaltung durch den
|
||||
Same-Origin-Mutationsschutz, aber nicht durch einen eigenen Token geschuetzt.
|
||||
|
||||
## Accepted Risk: dokumentierte externe Entscheidung
|
||||
|
||||
Ergaenzung 2026-09-10: Der lokale Accepted-Risk-Dialog speichert begruendete,
|
||||
|
|
|
|||
|
|
@ -2594,7 +2594,7 @@ Umgesetzter Grundstand vom 2026-09-15:
|
|||
Offen bis zur Phasenabnahme:
|
||||
|
||||
- Engine-Lesezugriff ueber eine read-only Datenbanksitzung wie bei der Report Engine (heute liest der Port ueber das laufende Core-Repository, weil ein Result-DB-Lesezugriff auf den Core-Neuaufbau warten koennen muss)
|
||||
- Frameworkneutrale Erweiterung um weitere Korpora (zum Beispiel BSI, DORA) mit Framework-Auswahl in der UI
|
||||
- Frameworkneutrale Erweiterung um weitere Korpora (zum Beispiel BSI, DORA) mit Framework-Auswahl in der UI; BSI Grundschutz++ ist als naechstes Framework beschlossen, siehe den folgenden Abschnitt
|
||||
- formaler Update-/Freigabeprozess fuer neue lizenzierte ISO-Ausgaben und Korpus-Rebuilds
|
||||
- kuratierte Erweiterung der oeffentlichen Signal-/Regelkataloge
|
||||
- repraesentativer freigegebener EN/DE-Evaluationssatz und dokumentierte reale Dense-Baseline gegen den autorisierten Korpus
|
||||
|
|
@ -2609,6 +2609,29 @@ Abnahmekriterien:
|
|||
- Jede gespeicherte Zuordnung referenziert Korpus-/Control-/Chunk-, Embedding-, Retrieval-, Reranker-, Prompt-/Schema- und Entscheidungsprovenienz.
|
||||
- Unzureichende oder widerspruechliche Evidenz bleibt reviewpflichtig; Retrievalscore allein wird nie als fachliche Confidence ausgegeben.
|
||||
|
||||
#### BSI Grundschutz++ in der Compliance Engine (Nutzerentscheidung 2026-10-08, in Arbeit)
|
||||
|
||||
Quelle ist die BSI-Stand-der-Technik-Bibliothek (`github.com/BSI-Bund/Stand-der-Technik-Bibliothek`, OSCAL-JSON, CC BY-SA 4.0, rollierende Veroeffentlichung ohne Releases oder Tags; Version ist `metadata.version`). Explizite Nutzerentscheidungen vom 2026-10-08:
|
||||
|
||||
- Ansatz: direkte Zuordnung (A) mit der offiziellen BSI-Zuordnung ISO/IEC 27001:2022 Annex A -> Grundschutz++ (B) als guenstiger erster Stufe.
|
||||
- Umfang: nur die 901 Kernel-Anforderungen (Klasse `BSI-Stand-der-Technik-Kernel-G0` im aufgeloesten Anwenderkatalog Grundschutz++). Methodik (95) und Risikomanagement (4) bleiben ausgeschlossen, weil Scannerbefunde kaum ISMS-Prozessanforderungen treffen und die Suche verwaessern wuerden.
|
||||
- Katalogbeschaffung wie bei der MITRE-Matrix: ein kompakter, versionsgepinnter Snapshot mit Quell-URL, Quell-SHA-256, Quellversion und Lizenzhinweis liegt im Enginepfad und wird von einem Engine-Skript aus den offiziellen Dateien erzeugt. Ableitung und Zuordnung brauchen kein Netz. Eine spaetere Aktion `Update from BSI` nach dem Muster `Update from MITRE` laedt feste offizielle Dateien und stellt eine neue Version bereit, ohne bestehende Ergebnisse zu ersetzen; sie kommt erst mit Aktualisierung des Sicherheitsmodells fuer dieses ausgehende Ziel.
|
||||
- Zuordnung zunaechst nur manuell: `Map new scans automatically` umfasst BSI nicht.
|
||||
|
||||
Stufen:
|
||||
|
||||
1. Stufe 1 (B, ohne Modell, ohne Migration): Aus den akzeptierten ISO-Kontrollen einer Regel werden ueber die BSI-Zuordnung Kernel-Anforderungen abgeleitet. Jede abgeleitete Anforderung nennt ISO-Quellkontrolle, Beziehung (`subset-of`, `equivalent-to`, `intersects-with`) und Zuordnungsversion, ist als abgeleitet gekennzeichnet, wird nicht als eigene Entscheidung gespeichert und aendert keine ISO-Entscheidung. Ziele der Zuordnung, die im Katalogsnapshot fehlen, werden als Diagnose ausgewiesen und nie umgedeutet (Stand Zuordnung 1.0.1-qa gegen Katalog vom 2026-09-24: `BER.5.1.2`, `BER.6.8`, `DET.3.1.13`, `GEB.5.4`; die uebrigen nicht aufloesbaren Ziele sind Methodik-IDs und fallen durch den Kernel-Umfang weg). Die 20 Praktiken (zum Beispiel `KONF`, `BER`, `DET`) uebernehmen die Rolle der ISO-Themen.
|
||||
2. Stufe 2 (A, manuell): eigener BSI-Korpus (Anforderung und Erlaeuterung) im bestehenden Qdrant-/BGE-M3-Pfad unter eigenem `corpusId`, eigene Promptversion fuer die deutschen `MUSS`/`SOLLTE`-Anforderungen, Start nur ueber ausdrueckliche Aktion (Einzel-Finding und Result-DB-Batch). Die Stufe-1-Ableitung dient als Evaluationsreferenz (aggregierte Konsistenz), nicht als Wahrheit. `assessor_regulatory_mappings` (Eindeutigkeit ohne Korpus) und `assessor_regulatory_mapping_decisions` kennen kein Framework; Stufe 2 braucht deshalb einen Framework-/Korpusdiskriminator per Migration mit separater Freigabe und verifiziertem Backup.
|
||||
|
||||
Darstellung: BSI-Texte duerfen unter CC BY-SA 4.0 mit Namensnennung im Browser erscheinen, anders als die ISO-Texte. Neue oder geaenderte Erweiterungspunkte (Finding-Reader-Abschnitt, Work-Facetten fuer BSI-Anforderung und -Praktik, Framework-Wahl bei Einzel- und Batchzuordnung, Data-Hub-Katalogkachel) sind Core-Aenderungen (Presentation) und brauchen Core-Freigabe. Die BSI-Kachel im Client-Info-Tab `Compliance` belegt den dafuer vorgesehenen Platz der Frameworkkachelzeile.
|
||||
|
||||
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] `Update from BSI` mit Sicherheitsmodell-Aktualisierung (Data-Hub-Kachel, in Produktversion 2.22.0 enthalten, 2026-10-08)
|
||||
|
||||
## 8. Aktuell empfohlene Umsetzungsetappe
|
||||
|
||||
2026-09-26 - Phase 13D / Phase 13C Darstellung (2.8.3, umgesetzt und verifiziert): Catalog references startet bei aktuellem Client eingeklappt; gruen vor Ueberschrift und aktuellem/erfolgreichem Status, orange und automatisch offen bei Handlungsbedarf oder Fehler. Pruefung, Verarbeitung und leere Clients bleiben neutral. Katalogversionen bleiben eingeklappt sichtbar, Erfolg nach Update bleibt offen, manuelles Umschalten funktioniert per Maus und Tastatur. Timeslots-Zeile mit gleichem Abstand oben/unten und gemeinsam zentrierten Controls. Purple-Knight-Timeslots benennen Active Directory, Entra ID oder kombinierten Scope aus bestehenden kanonischen Namen bzw. exakt lauf-/dateigebundenen gespeicherten Metadaten/Umgebungsbeobachtungen. Ohne Scopebeleg bleibt der Originalname; keine Vermutung aus Dateinamen oder fremden Scans, keine Persistenz-/Importer-/Scoringaenderung und keine neue Reimportpflicht. 36 gezielte Interaktions-/Layout-/Versionsvertragstests, beide Produktionsbuilds, Local-only-/Designcheck und synthetische Dark-/Light-Sicht- und Tastaturpruefung bestanden. App und Backend gesund auf 2.8.3 mit kanonischer PostgreSQL. Patchversion fuer zusammengehoerige UI-Korrekturen; Phase 13D bleibt in Arbeit. Die bekannte Gesamtsuite-Baseline mit sechs fachfremden Fehlern wurde nicht erneut ausgefuehrt.
|
||||
|
|
@ -2749,6 +2772,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-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. |
|
||||
| 2026-09-07 | Die geladene Client Info ist innerhalb der Wurzelklasse `.client-command-center` eine feste dunkle Command-Center-Ausnahme von der allgemeinen Kachelkomposition. Die genehmigte Hierarchie ist Client/Assessment/Source, sechs Icon-Fakten, Overall Score mit acht visuell verbundenen Security Domains, Priority Findings plus Assessment Gaps und abschliessend fuenf werkzeugspezifische Source-Flaechen. | Die ausdruecklich genehmigte Referenz ist ein zusammenhaengendes Erstgespraechs-Dashboard und keine Sammlung allgemeiner Anwendungskacheln. Eine eng begrenzte Ausnahme bildet diese Hierarchie originalgetreu ab, ohne Data Hub, Work, Result DB, Pathfinder oder Shell visuell zu veraendern. |
|
||||
|
|
@ -3685,6 +3709,10 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
|
|||
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
|
||||
|
||||
| Datum | Änderung |
|
||||
| 2026-10-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. |
|
||||
| 2026-10-08 | Phase 13F, BSI Grundschutz++ Stufe 1 Datenschicht (nur Engine-Code, keine Produktversion, weil noch nicht sichtbar): Konverter fuer die Kernel-Anforderungen des aufgeloesten Anwenderkatalogs (Klasse `BSI-Stand-der-Technik-Kernel-G0`, 901 Anforderungen; Praktik, Elternanforderung, Anforderungs- und Erlaeuterungstext unveraendert mit OSCAL-Parameterplatzhaltern, Parameterwert oder -bezeichnung, Modalverb, Sicherheits- und Aufwandsstufe) und fuer die BSI-Zuordnung ISO 27001 Annex A -> Kernel (83 Eintraege, Beziehung unveraendert). Fehlerhafte Eingaben (fehlende Version, ID oder Anforderung, wiederholte IDs, unbekannte Parameter, Beziehungen oder Quellen) scheitern ausdruecklich. Snapshots werden wie die MITRE-Kataloge aus den offiziellen Dateien des gepinnten BSI-Commits mit Quell-URL, Quell-SHA-256 und CC-BY-SA-Hinweis erzeugt; die Erzeugung ist bytegleich reproduzierbar. `deriveBsiRequirements` leitet aus akzeptierten ISO-Kontrollen Kernel-Anforderungen mit jeder ISO-Route reihenfolgeunabhaengig ab und weist im Katalog fehlende Ziele aus (`BER.5.1.2`, `BER.6.8`, `DET.3.1.13`, `GEB.5.4`). Das Generatorskript ist `.mjs`, weil die Server-Typpruefung keine `.ts`-Importendungen erlaubt. Tests: `tests/compliance/bsi-grundschutz.test.ts` (9); `npm test` 2156 bestanden, 18 uebersprungen; beide Typpruefungen bestanden. |
|
||||
| 2026-10-05 | Phasen 6/13F, in Produktversion 2.19.0 enthalten, Disclosure Policy 3.6.0 (Core-Freigabe Normalization, Disclosure, Presentation, Core Tests): Neues allgemeines Result-DB-Feld `technicalExplanation` fuer die technische Erklaerung einer Regel, wo ein Scanner sie liefert (heute PingCastle `Technical explanation`; andere Werkzeuge lassen es leer). Es wird beim Aufbau der Result DB aus dem bereits importierten Originalfeld gelesen (Schluessel `technical-explanation` oder Bezeichnung `Technical explanation`), daher ohne Reimport, in Local- und PostgreSQL-Repository ueber dieselbe Funktion. Es ist oeffentliche Regelanleitung wie `Description`, wird genauso streng redigiert (nur bekannte Scanner, unbekannte Quellen fail-closed, GPO ohne Feld), vom Identitaetsschutz geprueft und im inhaltsfreien Trace gefuehrt; `Details` mit Umgebungswerten bleibt zurueckgehalten. Die Policy-Erhoehung baut die gespeicherte Result DB neu auf. Result-DB-Details zeigen das Feld, Excel und CSV fuehren die Spalte nach `Description`. Die ISO-Zuordnung nimmt die redigierte technische Erklaerung in Suche und Modellanfrage auf; bestehende Zuordnungen behalten ihre ID. Tests: beide Importwege, Redaktion, Trace, Identitaetsschutz, fail-closed, interne Analyse mit Original, ISO-Abfrage. |
|
||||
| 2026-10-05 | Phase 13F, Produktversion 2.19.0 (Minor: neue Work-Faehigkeit; Core-Freigabe Governance, Presentation, Platform, Core Tests): Work erhaelt die Compliance-Engine-Facetten `ISO control` (je akzeptierter Kontrolle, `A.8.20 · Networks security`) und `ISO theme` (Organizational, People, Physical, Technological) wie die Context-Cluster-Facette, direkt vor dem permanenten `Source: Scan`, in gemischtem Work und beiden GPO-Einstiegen (Erweiterung der GPO-Facettenliste per Nutzerentscheidung). Grundlage ist der neue Endpunkt `GET /projects/:id/regulatory-control-assignments` mit den akzeptierten Kontrollen aller zuordenbaren Findings des Timeslots (nicht nur Abweichungen), damit der Work-Statusfilter wie gewohnt wirkt; Scanner-Findings ueber das Vorkommen, GPO-Einstellungen ueber Scope und technische Identitaet (gemeinsamer Helfer `gpoWorkSettingIdentity`). Eine Anfrage je Timeslot fuer alle fuenf Work-Verbraucher, Neuladen nach jeder Entscheidung, aktive Auswahl bleibt waehrend des Ladens; ausgeschaltet entfallen die Facetten und ein aktiver ISO-Filter wird entfernt. Themenwerte werden kleingeschrieben verglichen, die Anzeige bleibt ISO-Schreibweise. Die Client-Info-Kachel oeffnet Work jetzt mit Status `Deviation` und der Kontrolle im `ISO control`-Filter statt einer festen Record-Liste. Aktivitaetslabel `Loading ISO control filters`. Tests: Werte, Zaehlung gegen andere Filter, Scanner- und GPO-Abgleich, Thema, Position vor `Source: Scan`, geteilte Anfrage, Neuladen, Engine aus mit Filterentfernung, Kachel-Sprung; Version-UI/API-Tests und beide Builds. |
|
||||
| 2026-10-05 | Phase 13F, in Produktversion 2.18.0 enthalten (nur Engine-Code), vorlaeufige Nutzerentscheidung: Nach einer erfolgreichen Zuordnung werden die ersten zwei Vorschlaege in der Reihenfolge des Modells automatisch akzeptiert (bei nur einem Vorschlag nur dieser), mit dem Kommentar `Accepted automatically: one of the first two proposals of the mapping.`; eine Kontrolle mit bestehender Entscheidung behaelt sie, ein manuelles Reject wird also nie ueberschrieben, und ein Fehler dabei laesst die Zuordnung erfolgreich. Gilt fuer Einzel- und Batchzuordnung. Einmalige Nachholung ueber die normale Entscheidungs-API fuer die bestehenden 130 Regeln mit erfolgreichem Lauf (129 mit Vorschlaegen, 1 ohne): 210 Entscheidungen fuer 112 Regeln angelegt (98 mit zwei, 14 mit einer akzeptierten Kontrolle), 33 bereits entschiedene Kontrollen uebersprungen, keine Fehler. Die Regel steht seit der Governance-Freigabe vom selben Tag als vorlaeufige Nutzerentscheidung in den verbindlichen Projektregeln. |
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.21.0",
|
||||
"version": "2.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "assessor",
|
||||
"version": "2.21.0",
|
||||
"version": "2.22.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.21.0",
|
||||
"version": "2.22.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import type { RegulatoryMappingService } from './compliance/regulatory-mapping-s
|
|||
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 { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service';
|
||||
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
|
||||
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
|
||||
|
|
@ -179,6 +180,8 @@ export interface AssessorHttpServerOptions {
|
|||
regulatoryMappingService?: RegulatoryMappingService;
|
||||
regulatoryCorpusUploadService?: RegulatoryCorpusUploadService;
|
||||
complianceExport?: Pick<ComplianceStore, 'exportProject'>;
|
||||
/** Compliance Engine: active BSI Kernel catalog and ISO mapping with Update from BSI. */
|
||||
bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>;
|
||||
isoUploadToken?: string;
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +414,7 @@ function statusForError(error: unknown): { status: number; code: string; message
|
|||
if (error instanceof AiOverviewServiceError) return { status: error.status, code: error.code, message: error.message };
|
||||
if (error instanceof RegulatoryMappingServiceError) return { status: error.status, code: error.code, message: error.message };
|
||||
if (error instanceof RegulatoryCorpusUploadError) return { status: error.status, code: error.code, message: error.message };
|
||||
if (error instanceof BsiCatalogError) return { status: 502, code: error.code, message: error.message };
|
||||
return { status: 500, code: 'internal_error', message: 'Die Anfrage konnte nicht verarbeitet werden.' };
|
||||
}
|
||||
|
||||
|
|
@ -775,6 +779,20 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
|||
...corpusStatus, configured: false, disabledReason: 'An ISO upload token of at least 32 characters must be configured.'
|
||||
}, requestId); return;
|
||||
}
|
||||
if (segments.join('/') === 'reference-data/bsi-grundschutz' && method === 'GET') {
|
||||
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
|
||||
status = 200; sendJson(response, status, await options.bsiCatalogService.current(), requestId); return;
|
||||
}
|
||||
if (segments.join('/') === 'reference-data/bsi-grundschutz/refresh' && 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);
|
||||
status = 200; sendJson(response, status, await options.bsiCatalogService.refresh(), 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);
|
||||
status = 200; sendJson(response, status, await options.bsiCatalogService.reset(), requestId); return;
|
||||
}
|
||||
if (segments.join('/') === 'reference-data/iso-corpus/auto-mapping' && method === 'PUT') {
|
||||
if (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
|
||||
const body = await readJson<{ enabled: boolean }>(request, maximumBodyBytes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute } from 'node:path';
|
||||
import { BSI_KERNEL_CLASS, bsiIsoCrosswalk, bsiKernelCatalog, type BsiIsoCrosswalk, type BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz';
|
||||
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
|
||||
import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots';
|
||||
|
||||
// Fixed official sources (security model: outbound HTTPS to raw.githubusercontent.com only, user decision 2026-10-08).
|
||||
// BSI publishes continuously without releases, so the update reads `main` and records the SHA-256 of what it read.
|
||||
const BSI_BASE = 'https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/main/control_layer';
|
||||
export const BSI_CATALOG_SOURCE_URL = `${BSI_BASE}/Grundschutz++/Grundschutz++-resolved_catalog.json`;
|
||||
export const BSI_CROSSWALK_SOURCE_URL = `${BSI_BASE}/Mappings/ISO-27001-zu-GSpp/ISO27001-AnnexA-to-GS++-mapping_collection.json`;
|
||||
const MAXIMUM_SOURCE_BYTES = 32 * 1024 * 1024;
|
||||
const MAXIMUM_STORED_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export class BsiCatalogError extends Error {
|
||||
constructor(readonly code: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'BsiCatalogError';
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredBsiCatalogs extends BsiCatalogs {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active BSI Kernel catalog and ISO 27001 mapping of the Compliance Engine. The pinned snapshots stay active
|
||||
* until `Update from BSI` stores a newer official state in the engine's private folder; derived requirements
|
||||
* follow the active state at once, because they are never stored.
|
||||
*/
|
||||
export class BsiCatalogService {
|
||||
private refreshInProgress?: Promise<BsiCatalogState & { updated: boolean }>;
|
||||
private cached?: Promise<{ catalogs: BsiCatalogs; updatedAt?: string }>;
|
||||
|
||||
constructor(
|
||||
private readonly storagePath: string,
|
||||
private readonly fetcher: typeof fetch = globalThis.fetch.bind(globalThis),
|
||||
private readonly now: () => Date = () => new Date()
|
||||
) {
|
||||
if (!storagePath || !isAbsolute(storagePath)) throw new Error('An absolute BSI catalog storage path is required.');
|
||||
}
|
||||
|
||||
/** The active catalogs for the derivation. */
|
||||
async catalogs(): Promise<BsiCatalogs> {
|
||||
return (await this.active()).catalogs;
|
||||
}
|
||||
|
||||
async current(): Promise<BsiCatalogState> {
|
||||
const { catalogs, updatedAt } = await this.active();
|
||||
return state(catalogs, updatedAt);
|
||||
}
|
||||
|
||||
refresh(): Promise<BsiCatalogState & { updated: boolean }> {
|
||||
if (this.refreshInProgress) return this.refreshInProgress;
|
||||
this.refreshInProgress = this.performRefresh().finally(() => { this.refreshInProgress = undefined; });
|
||||
return this.refreshInProgress;
|
||||
}
|
||||
|
||||
/** Returns to the pinned snapshots. */
|
||||
async reset(): Promise<BsiCatalogState> {
|
||||
await unlink(this.storagePath).catch(error => {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
||||
});
|
||||
this.cached = undefined;
|
||||
return this.current();
|
||||
}
|
||||
|
||||
private active(): Promise<{ catalogs: BsiCatalogs; updatedAt?: string }> {
|
||||
this.cached ??= this.read().catch(error => { this.cached = undefined; throw error; });
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
private async read(): Promise<{ catalogs: BsiCatalogs; updatedAt?: string }> {
|
||||
let text: string;
|
||||
try {
|
||||
const information = await stat(this.storagePath);
|
||||
if (!information.isFile() || information.size > MAXIMUM_STORED_BYTES) throw new BsiCatalogError('bsi_storage_invalid', 'The stored BSI catalog is not a regular bounded file.');
|
||||
text = await readFile(this.storagePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return { catalogs: bundledBsiCatalogs };
|
||||
throw error;
|
||||
}
|
||||
const stored = JSON.parse(text) as Partial<StoredBsiCatalogs>;
|
||||
if (stored.version !== 1 || typeof stored.updatedAt !== 'string' || !validCatalog(stored.catalog) || !validCrosswalk(stored.crosswalk)) {
|
||||
throw new BsiCatalogError('bsi_storage_invalid', 'The stored BSI catalog is invalid. Use the bundled catalog or update from BSI again.');
|
||||
}
|
||||
// A stored state older than the bundled snapshot is superseded by it.
|
||||
if (stored.catalog.version < bundledBsiCatalogs.catalog.version) return { catalogs: bundledBsiCatalogs };
|
||||
return { catalogs: { catalog: stored.catalog, crosswalk: stored.crosswalk }, updatedAt: stored.updatedAt };
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<BsiCatalogState & { updated: boolean }> {
|
||||
const [catalogSource, crosswalkSource] = await Promise.all([download(this.fetcher, BSI_CATALOG_SOURCE_URL), download(this.fetcher, BSI_CROSSWALK_SOURCE_URL)]);
|
||||
let latest: BsiCatalogs;
|
||||
try {
|
||||
latest = {
|
||||
catalog: bsiKernelCatalog(JSON.parse(catalogSource.toString('utf8')), BSI_CATALOG_SOURCE_URL, sha256(catalogSource)),
|
||||
crosswalk: bsiIsoCrosswalk(JSON.parse(crosswalkSource.toString('utf8')), BSI_CROSSWALK_SOURCE_URL, sha256(crosswalkSource))
|
||||
};
|
||||
} catch (error) {
|
||||
throw new BsiCatalogError('bsi_source_invalid', `The BSI files are not a usable Grundschutz++ catalog and ISO mapping: ${error instanceof Error ? error.message : 'invalid content'}`);
|
||||
}
|
||||
const current = await this.active();
|
||||
const unchanged = latest.catalog.sourceSha256 === current.catalogs.catalog.sourceSha256
|
||||
&& latest.crosswalk.sourceSha256 === current.catalogs.crosswalk.sourceSha256;
|
||||
// Never step back behind the active catalog.
|
||||
if (unchanged || latest.catalog.version < current.catalogs.catalog.version) return { ...state(current.catalogs, current.updatedAt), updated: false };
|
||||
const directory = dirname(this.storagePath);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const directoryInfo = await lstat(directory);
|
||||
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) throw new BsiCatalogError('bsi_storage_unavailable', 'The private BSI catalog storage is not available.');
|
||||
const stored: StoredBsiCatalogs = { version: 1, updatedAt: this.now().toISOString(), ...latest };
|
||||
const temporaryPath = `${this.storagePath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(temporaryPath, JSON.stringify(stored), { mode: 0o600 });
|
||||
await rename(temporaryPath, this.storagePath);
|
||||
} finally {
|
||||
await unlink(temporaryPath).catch(error => {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
||||
});
|
||||
}
|
||||
this.cached = Promise.resolve({ catalogs: latest, updatedAt: stored.updatedAt });
|
||||
return { ...state(latest, stored.updatedAt), updated: true };
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
async function download(fetcher: typeof fetch, url: string): Promise<Buffer> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, { headers: { Accept: 'application/json' }, cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(120_000) });
|
||||
} catch {
|
||||
throw new BsiCatalogError('bsi_unreachable', 'BSI could not be reached. Please try again later.');
|
||||
}
|
||||
if (!response.ok || !response.body) throw new BsiCatalogError('bsi_unavailable', `BSI returned no usable file (HTTP ${response.status}).`);
|
||||
const declaredSize = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declaredSize) && declaredSize > MAXIMUM_SOURCE_BYTES) {
|
||||
await response.body.cancel();
|
||||
throw new BsiCatalogError('bsi_source_too_large', 'A BSI file exceeds the size limit.');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
const reader = response.body.getReader();
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > MAXIMUM_SOURCE_BYTES) throw new BsiCatalogError('bsi_source_too_large', 'A BSI file exceeds the size limit.');
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
if (error instanceof BsiCatalogError) throw error;
|
||||
throw new BsiCatalogError('bsi_download_failed', 'The BSI download was interrupted.');
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
}
|
||||
|
||||
function validCatalog(value: unknown): value is BsiKernelCatalog {
|
||||
const catalog = value as BsiKernelCatalog | undefined;
|
||||
return catalog?.schemaVersion === 1 && catalog.framework === 'bsi-grundschutz-plus-plus' && catalog.catalogClass === BSI_KERNEL_CLASS
|
||||
&& typeof catalog.version === 'string' && /^[0-9a-f]{64}$/.test(catalog.sourceSha256 ?? '')
|
||||
&& Array.isArray(catalog.practices) && Array.isArray(catalog.requirements) && catalog.requirements.length > 0;
|
||||
}
|
||||
|
||||
function validCrosswalk(value: unknown): value is BsiIsoCrosswalk {
|
||||
const crosswalk = value as BsiIsoCrosswalk | undefined;
|
||||
return crosswalk?.schemaVersion === 1 && crosswalk.framework === 'bsi-iso27001-annex-a-to-grundschutz-plus-plus'
|
||||
&& typeof crosswalk.version === 'string' && /^[0-9a-f]{64}$/.test(crosswalk.sourceSha256 ?? '') && Array.isArray(crosswalk.entries);
|
||||
}
|
||||
|
||||
function state({ catalog, crosswalk }: BsiCatalogs, updatedAt: string | undefined): BsiCatalogState {
|
||||
const ids = new Set(catalog.requirements.map(item => item.id));
|
||||
return {
|
||||
version: 1,
|
||||
origin: updatedAt ? 'bsi' : 'bundled',
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
catalogVersion: catalog.version,
|
||||
crosswalkVersion: crosswalk.version,
|
||||
requirementCount: catalog.requirements.length,
|
||||
crosswalkEntryCount: crosswalk.entries.length,
|
||||
catalogSha256: catalog.sourceSha256,
|
||||
crosswalkSha256: crosswalk.sourceSha256,
|
||||
missingTargets: [...new Set(crosswalk.entries.flatMap(entry => entry.targets.filter(target => !ids.has(target))))].sort()
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import catalog from './data/bsi-grundschutz-kernel-2026-09-24.json';
|
||||
import crosswalk from './data/bsi-iso27001-crosswalk-1.0.1-qa.json';
|
||||
import type { BsiIsoCrosswalk, BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz';
|
||||
|
||||
/** A Kernel catalog with the BSI ISO 27001 mapping that belongs to it. */
|
||||
export interface BsiCatalogs {
|
||||
catalog: BsiKernelCatalog;
|
||||
crosswalk: BsiIsoCrosswalk;
|
||||
}
|
||||
|
||||
// Pinned BSI snapshots (Phase 13F, user decision 2026-10-08); regenerate with update-bsi-assets.mjs.
|
||||
// They stay active until `Update from BSI` stores a newer official state.
|
||||
export const bundledBsiCatalogs: BsiCatalogs = { catalog: catalog as BsiKernelCatalog, crosswalk: crosswalk as BsiIsoCrosswalk };
|
||||
|
|
@ -11,11 +11,13 @@ import { RegulatoryMappingService } from './regulatory-mapping-service';
|
|||
import { RegulatoryCorpusUploadService } from './regulatory-corpus-upload-service';
|
||||
import { RegulatoryAutoMapping } from './regulatory-auto-mapping';
|
||||
import { AiOverviewService, type AiOverviewServiceOptions } from './ai-overview-service';
|
||||
import { BsiCatalogService } from './bsi-catalog-service';
|
||||
|
||||
export interface ComplianceEngineRuntime {
|
||||
mappingService: RegulatoryMappingService;
|
||||
corpusUploadService: RegulatoryCorpusUploadService;
|
||||
aiOverviewService: AiOverviewService;
|
||||
bsiCatalogService: BsiCatalogService;
|
||||
store: ComplianceStore;
|
||||
/** Stops running work; ASSESSOR keeps its own repository open. */
|
||||
stop(): Promise<void>;
|
||||
|
|
@ -40,6 +42,10 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
|
|||
const pool = options.persistence === 'postgresql' ? compliancePool(options.environment ?? process.env as RepositoryEnvironment) : undefined;
|
||||
const store: ComplianceStore = pool ? new PostgresComplianceStore(pool) : new LocalComplianceStore();
|
||||
const read = complianceReadPortFrom(options.repository);
|
||||
const corpusRoot = join(options.artifactRoot, 'regulatory-corpora');
|
||||
// The active BSI catalog and ISO mapping; the pinned snapshots until Update from BSI stores a newer state.
|
||||
const bsiCatalogService = new BsiCatalogService(join(corpusRoot, 'bsi', 'active-catalog.json'));
|
||||
const bsiCatalogs = () => bsiCatalogService.catalogs();
|
||||
let mappingService: RegulatoryMappingService;
|
||||
let rag: ReturnType<typeof createRegulatoryRagRuntime> | undefined;
|
||||
let disabledReason: string | undefined;
|
||||
|
|
@ -52,14 +58,14 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
|
|||
client: options.aiClient,
|
||||
corpusId: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID?.trim(),
|
||||
corpusVersion: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION?.trim(),
|
||||
bsiCatalogs,
|
||||
...(!options.aiClient ? { disabledReason: 'Work LiteLLM is not configured.' } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
disabledReason = error instanceof Error ? error.message : 'Regulatory mapping is unavailable.';
|
||||
mappingService = new RegulatoryMappingService(read, store, { disabledReason });
|
||||
mappingService = new RegulatoryMappingService(read, store, { disabledReason, bsiCatalogs });
|
||||
}
|
||||
await mappingService.recoverInterruptedRuns();
|
||||
const corpusRoot = join(options.artifactRoot, 'regulatory-corpora');
|
||||
const autoMapping = new RegulatoryAutoMapping({ settingsPath: join(corpusRoot, 'auto-mapping.json'), read, mapping: mappingService });
|
||||
const corpusUploadService = new RegulatoryCorpusUploadService({
|
||||
root: corpusRoot,
|
||||
|
|
@ -79,6 +85,7 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
|
|||
mappingService,
|
||||
corpusUploadService,
|
||||
aiOverviewService,
|
||||
bsiCatalogService,
|
||||
store,
|
||||
stop: () => Promise.all([autoMapping.stop(), corpusUploadService.stop().then(() => mappingService.close()), aiOverviewService.close()]).then(() => undefined),
|
||||
close: async () => { await pool?.end(); }
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -38,6 +38,8 @@ import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulato
|
|||
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 { bsiOverview, type BsiOverview } from '../../src/compliance/bsi-grundschutz';
|
||||
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';
|
||||
import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
|
||||
import { canonicalJson } from '../../src/shared/canonical-json';
|
||||
|
|
@ -61,6 +63,8 @@ export interface RegulatoryMappingServiceOptions {
|
|||
disabledReason?: string;
|
||||
now?: () => Date;
|
||||
maximumConcurrentRuns?: number;
|
||||
/** The active BSI catalog and mapping (`Update from BSI`); the pinned snapshots by default. */
|
||||
bsiCatalogs?: () => Promise<BsiCatalogs>;
|
||||
}
|
||||
|
||||
export class RegulatoryMappingService {
|
||||
|
|
@ -136,6 +140,7 @@ export class RegulatoryMappingService {
|
|||
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));
|
||||
return {
|
||||
version: 1,
|
||||
projectId,
|
||||
|
|
@ -149,7 +154,8 @@ export class RegulatoryMappingService {
|
|||
...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}),
|
||||
...(run ? { run: withoutEvidenceText(run) } : {}),
|
||||
decisions,
|
||||
...(earlierControls.length ? { earlierControls } : {})
|
||||
...(earlierControls.length ? { earlierControls } : {}),
|
||||
...(bsi ? { bsi } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -263,6 +269,12 @@ export class RegulatoryMappingService {
|
|||
return this.get(projectId, request.resultProjectionId);
|
||||
}
|
||||
|
||||
/** BSI Kernel requirements derived from accepted ISO controls through the active BSI mapping (Phase 13F, stage 1). */
|
||||
private async bsiOverview(acceptedIsoControlIds: readonly string[]): Promise<BsiOverview | undefined> {
|
||||
const { catalog, crosswalk } = this.options.bsiCatalogs ? await this.options.bsiCatalogs() : bundledBsiCatalogs;
|
||||
return bsiOverview(acceptedIsoControlIds, crosswalk, catalog);
|
||||
}
|
||||
|
||||
/** Accepted controls of every mappable finding in a client timeslot, for the Work `ISO control` and `ISO theme` filters. */
|
||||
async controlAssignments(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> {
|
||||
return this.controlOverview(projectId, requested, { deviationsOnly: false });
|
||||
|
|
@ -310,12 +322,14 @@ export class RegulatoryMappingService {
|
|||
controls.set(controlId, entry);
|
||||
}
|
||||
}
|
||||
const bsi = await this.bsiOverview([...controls.keys()]);
|
||||
return {
|
||||
version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size,
|
||||
controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] }))
|
||||
.sort((left, right) => right.findings.length - left.findings.length
|
||||
|| left.controlId.localeCompare(right.controlId, 'en', { numeric: true })),
|
||||
...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope) } : {})
|
||||
...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope) } : {}),
|
||||
...(bsi ? { bsi } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { bsiIsoCrosswalk, bsiKernelCatalog } from '../../src/compliance/bsi-grundschutz.ts';
|
||||
|
||||
// Regenerates the pinned BSI snapshots from official files downloaded from these exact commit URLs.
|
||||
const commit = process.argv[4];
|
||||
const [catalogPath, crosswalkPath] = process.argv.slice(2);
|
||||
if (!catalogPath || !crosswalkPath || !/^[0-9a-f]{40}$/.test(commit ?? '')) {
|
||||
throw new Error('Usage: node --experimental-strip-types server/compliance/update-bsi-assets.mjs <Grundschutz++-resolved_catalog.json> <ISO27001-AnnexA-to-GS++-mapping_collection.json> <BSI commit sha>');
|
||||
}
|
||||
const base = `https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/${commit}/control_layer`;
|
||||
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
const catalogBytes = await readFile(catalogPath);
|
||||
const catalog = bsiKernelCatalog(JSON.parse(catalogBytes.toString('utf8')), `${base}/Grundschutz++/Grundschutz++-resolved_catalog.json`, sha256(catalogBytes));
|
||||
const catalogOutput = `server/compliance/data/bsi-grundschutz-kernel-${catalog.version.slice(0, 10)}.json`;
|
||||
await writeFile(catalogOutput, JSON.stringify(catalog) + '\n');
|
||||
console.log(`${catalogOutput}: ${catalog.requirements.length} requirements`);
|
||||
|
||||
const crosswalkBytes = await readFile(crosswalkPath);
|
||||
const crosswalk = bsiIsoCrosswalk(JSON.parse(crosswalkBytes.toString('utf8')),
|
||||
`${base}/Mappings/ISO-27001-zu-GSpp/ISO27001-AnnexA-to-GS++-mapping_collection.json`, sha256(crosswalkBytes));
|
||||
const crosswalkOutput = `server/compliance/data/bsi-iso27001-crosswalk-${crosswalk.version}.json`;
|
||||
await writeFile(crosswalkOutput, JSON.stringify(crosswalk) + '\n');
|
||||
console.log(`${crosswalkOutput}: ${crosswalk.entries.length} entries`);
|
||||
|
|
@ -151,6 +151,7 @@ async function main(): Promise<void> {
|
|||
aiOverviewService: complianceEngine.aiOverviewService,
|
||||
regulatoryMappingService: complianceEngine.mappingService,
|
||||
regulatoryCorpusUploadService: complianceEngine.corpusUploadService,
|
||||
bsiCatalogService: complianceEngine.bsiCatalogService,
|
||||
complianceExport: complianceEngine.store,
|
||||
isoUploadToken: process.env.ASSESSOR_ISO_UPLOAD_TOKEN?.trim(),
|
||||
automaticGpoAnalysis,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { ClientInformationQuery, ClientInformationResult } from '../domain/
|
|||
import type { ImportedDataset } from '../domain/dataset';
|
||||
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
|
||||
import type { RegulatoryCorpusStatus } from '../compliance/regulatory-corpus-management';
|
||||
import type { BsiCatalogRefreshResult, BsiCatalogState } from '../compliance/bsi-catalog-management';
|
||||
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
|
||||
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||
import {
|
||||
|
|
@ -480,6 +481,18 @@ export class AssessorApiClient {
|
|||
return this.request('/reference-data/microsoft-security-baselines');
|
||||
}
|
||||
|
||||
getBsiCatalog(): Promise<BsiCatalogState> {
|
||||
return this.request('/reference-data/bsi-grundschutz');
|
||||
}
|
||||
|
||||
refreshBsiCatalog(): Promise<BsiCatalogRefreshResult> {
|
||||
return this.request('/reference-data/bsi-grundschutz/refresh', { method: 'POST', body: '{}' });
|
||||
}
|
||||
|
||||
resetBsiCatalog(): Promise<BsiCatalogState> {
|
||||
return this.request('/reference-data/bsi-grundschutz/reset', { method: 'POST', body: '{}' });
|
||||
}
|
||||
|
||||
getIsoCorpusStatus(): Promise<RegulatoryCorpusStatus> {
|
||||
return this.request('/reference-data/iso-corpus');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,5 +35,8 @@ function complianceActivity(route: string, method: string): string | undefined {
|
|||
if (route.includes('/regulatory-mapping-decisions')) return 'Saving ISO mapping decision';
|
||||
if (route.includes('/regulatory-mappings/')) return 'Loading ISO mapping';
|
||||
if (route.endsWith('/reference-data/iso-corpus')) return write ? 'Uploading ISO corpus' : 'Loading ISO corpus status';
|
||||
if (route.endsWith('/reference-data/bsi-grundschutz/refresh')) return 'Updating BSI catalog';
|
||||
if (route.endsWith('/reference-data/bsi-grundschutz/reset')) return 'Restoring bundled BSI catalog';
|
||||
if (route.endsWith('/reference-data/bsi-grundschutz')) return 'Loading BSI catalog';
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
/** Data Hub BSI catalog tile: which BSI Kernel catalog and ISO 27001 mapping are active, and where from. */
|
||||
export interface BsiCatalogState {
|
||||
version: 1;
|
||||
/** `bundled`: the pinned snapshots shipped with ASSESSOR; `bsi`: stored by `Update from BSI`. */
|
||||
origin: 'bundled' | 'bsi';
|
||||
updatedAt?: string;
|
||||
catalogVersion: string;
|
||||
crosswalkVersion: string;
|
||||
requirementCount: number;
|
||||
crosswalkEntryCount: number;
|
||||
catalogSha256: string;
|
||||
crosswalkSha256: string;
|
||||
/** Mapping targets the active catalog does not contain; they are reported, never reinterpreted. */
|
||||
missingTargets: string[];
|
||||
}
|
||||
|
||||
export interface BsiCatalogRefreshResult extends BsiCatalogState {
|
||||
updated: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* BSI Grundschutz++ for the Compliance Engine (Phase 13F, user decision 2026-10-08): the Kernel requirements of
|
||||
* the BSI Stand-der-Technik-Bibliothek and the official BSI mapping ISO/IEC 27001:2022 Annex A -> Grundschutz++,
|
||||
* converted into compact, version-pinned snapshots like the MITRE reference catalogs. Source prose is kept as
|
||||
* published, including its OSCAL parameter placeholders; `bsiRequirementText` resolves them for display.
|
||||
*/
|
||||
|
||||
export const BSI_KERNEL_CLASS = 'BSI-Stand-der-Technik-Kernel-G0';
|
||||
export const BSI_NOTICE = 'Source: German Federal Office for Information Security (BSI), Stand-der-Technik-Bibliothek, '
|
||||
+ 'https://github.com/BSI-Bund/Stand-der-Technik-Bibliothek. License: Creative Commons Attribution-ShareAlike 4.0 International '
|
||||
+ '(CC BY-SA 4.0), https://creativecommons.org/licenses/by-sa/4.0/. Compacted; the requirement content is unchanged.';
|
||||
|
||||
export type BsiCrosswalkRelationship = 'equivalent-to' | 'subset-of' | 'superset-of' | 'intersects-with';
|
||||
|
||||
export interface BsiRequirement {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Top-level practice of the requirement, for example `KONF`. */
|
||||
practice: string;
|
||||
parentId?: string;
|
||||
/** Statement prose exactly as published, including `{{ insert: param, <id> }}` placeholders. */
|
||||
statement: string;
|
||||
modalVerb?: string;
|
||||
guidance?: string;
|
||||
securityLevel?: string;
|
||||
effortLevel?: string;
|
||||
/** Parameter id -> its published value, or its label where no value is set. */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BsiKernelCatalog {
|
||||
schemaVersion: 1;
|
||||
framework: 'bsi-grundschutz-plus-plus';
|
||||
catalogClass: typeof BSI_KERNEL_CLASS;
|
||||
title: string;
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
sourceSha256: string;
|
||||
license: string;
|
||||
practices: Array<{ id: string; title: string }>;
|
||||
requirements: BsiRequirement[];
|
||||
}
|
||||
|
||||
export interface BsiCrosswalkEntry {
|
||||
uuid: string;
|
||||
isoControlId: string;
|
||||
relationship: BsiCrosswalkRelationship;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface BsiIsoCrosswalk {
|
||||
schemaVersion: 1;
|
||||
framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus';
|
||||
title: string;
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
sourceSha256: string;
|
||||
license: string;
|
||||
/** Only the mapping whose target is the Kernel catalog; the Methodik mapping is outside the decided scope. */
|
||||
targetCatalog: string;
|
||||
entries: BsiCrosswalkEntry[];
|
||||
}
|
||||
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
const object = (value: unknown): ObjectValue => value && typeof value === 'object' && !Array.isArray(value) ? value as ObjectValue : {};
|
||||
const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
|
||||
const text = (value: unknown): string => typeof value === 'string' ? value : '';
|
||||
const prop = (props: unknown, name: string): string | undefined => list(props).map(object).find(item => item.name === name)?.value as string | undefined;
|
||||
|
||||
const REQUIREMENT_ID = /^[A-Z]{2,5}(?:\.\d+)+$/;
|
||||
const PARAM_INSERT = /\{\{\s*insert:\s*param,\s*([^\s}]+)\s*\}\}/g;
|
||||
const RELATIONSHIPS = new Set<string>(['equivalent-to', 'subset-of', 'superset-of', 'intersects-with']);
|
||||
|
||||
export function bsiKernelCatalog(raw: unknown, sourceUrl: string, sourceSha256: string): BsiKernelCatalog {
|
||||
const catalog = object(object(raw).catalog);
|
||||
const metadata = object(catalog.metadata);
|
||||
const version = text(metadata.version);
|
||||
if (!version || !Array.isArray(catalog.groups)) throw new Error('Expected an OSCAL Grundschutz++ catalog with a version.');
|
||||
const practices: BsiKernelCatalog['practices'] = [];
|
||||
const requirements: BsiRequirement[] = [];
|
||||
const visitControls = (controls: unknown, practice: string, parentId?: string) => {
|
||||
for (const control of list(controls).map(object)) {
|
||||
const id = text(control.id);
|
||||
if (control.class === BSI_KERNEL_CLASS) {
|
||||
if (!REQUIREMENT_ID.test(id)) throw new Error(`Invalid Kernel requirement id: ${id || '(missing)'}`);
|
||||
const parts = list(control.parts).map(object);
|
||||
const statementPart = parts.find(part => part.name === 'statement');
|
||||
const statement = text(statementPart?.prose);
|
||||
if (!statement) throw new Error(`Kernel requirement ${id} has no statement.`);
|
||||
const params = Object.fromEntries(list(control.params).map(object).map(param => {
|
||||
const values = list(param.values).filter((value): value is string => typeof value === 'string');
|
||||
return [text(param.id), values.length === 1 ? values[0] : text(param.label)];
|
||||
}));
|
||||
for (const part of parts) {
|
||||
for (const match of text(part.prose).matchAll(PARAM_INSERT)) {
|
||||
if (!params[match[1]]) throw new Error(`Kernel requirement ${id} inserts unknown parameter ${match[1]}.`);
|
||||
}
|
||||
}
|
||||
const guidance = text(parts.find(part => part.name === 'guidance')?.prose);
|
||||
requirements.push({
|
||||
id, title: text(control.title), practice, ...(parentId ? { parentId } : {}), statement,
|
||||
...optional('modalVerb', prop(statementPart?.props, 'modal_verb')),
|
||||
...optional('guidance', guidance || undefined),
|
||||
...optional('securityLevel', prop(control.props, 'sec_level')),
|
||||
...optional('effortLevel', prop(control.props, 'effort_level')),
|
||||
...(Object.keys(params).length ? { params } : {})
|
||||
});
|
||||
}
|
||||
visitControls(control.controls, practice, id);
|
||||
}
|
||||
};
|
||||
const visitGroups = (groups: unknown, practice?: string) => {
|
||||
for (const group of list(groups).map(object)) {
|
||||
const own = practice ?? text(group.id);
|
||||
if (!practice) practices.push({ id: own, title: text(group.title) });
|
||||
visitControls(group.controls, own);
|
||||
visitGroups(group.groups, own);
|
||||
}
|
||||
};
|
||||
visitGroups(catalog.groups);
|
||||
if (!requirements.length) throw new Error('The catalog contains no Kernel requirements.');
|
||||
const ids = requirements.map(item => item.id);
|
||||
const repeated = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||||
if (repeated.length) throw new Error(`Repeated Kernel requirement ids: ${[...new Set(repeated)].join(', ')}`);
|
||||
const used = new Set(requirements.map(item => item.practice));
|
||||
return {
|
||||
schemaVersion: 1, framework: 'bsi-grundschutz-plus-plus', catalogClass: BSI_KERNEL_CLASS, title: text(metadata.title), version,
|
||||
sourceUrl, sourceSha256, license: BSI_NOTICE, practices: practices.filter(item => used.has(item.id)), requirements
|
||||
};
|
||||
}
|
||||
|
||||
export function bsiIsoCrosswalk(raw: unknown, sourceUrl: string, sourceSha256: string): BsiIsoCrosswalk {
|
||||
const collection = object(object(raw)['mapping-collection']);
|
||||
const metadata = object(collection.metadata);
|
||||
const version = text(metadata.version);
|
||||
if (!version || !Array.isArray(collection.mappings)) throw new Error('Expected an OSCAL mapping collection with a version.');
|
||||
const kernel = collection.mappings.map(object).filter(mapping => /Kernel/i.test(text(object(mapping['target-resource']).href)));
|
||||
if (kernel.length !== 1) throw new Error(`Expected exactly one mapping to the Kernel catalog, found ${kernel.length}.`);
|
||||
const entries = list(kernel[0].maps).map(object).map((map): BsiCrosswalkEntry => {
|
||||
const relationship = text(map.relationship);
|
||||
const sources = list(map.sources).map(object).map(source => text(source['id-ref']));
|
||||
const targets = list(map.targets).map(object).map(target => text(target['id-ref']));
|
||||
if (!RELATIONSHIPS.has(relationship)) throw new Error(`Unknown mapping relationship: ${relationship || '(missing)'}`);
|
||||
if (sources.length !== 1 || !/^[5-8]\.\d+$/.test(sources[0])) throw new Error(`Expected one ISO Annex A source control, found ${sources.join(', ') || 'none'}.`);
|
||||
if (!targets.length || targets.some(target => !REQUIREMENT_ID.test(target))) throw new Error(`Invalid mapping targets for ISO ${sources[0]}.`);
|
||||
return { uuid: text(map.uuid), isoControlId: sources[0], relationship: relationship as BsiCrosswalkRelationship, targets };
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: text(metadata.title), version,
|
||||
sourceUrl, sourceSha256, license: BSI_NOTICE, targetCatalog: text(object(kernel[0]['target-resource']).href), entries
|
||||
};
|
||||
}
|
||||
|
||||
/** Statement or guidance prose with each parameter placeholder replaced by its published value or label. */
|
||||
export function bsiRequirementText(prose: string, params: Record<string, string> | undefined): string {
|
||||
return prose.replace(PARAM_INSERT, (placeholder, id: string) => params?.[id] ?? placeholder);
|
||||
}
|
||||
|
||||
export interface BsiDerivedRequirement {
|
||||
requirementId: string;
|
||||
title: string;
|
||||
practice: string;
|
||||
/** Every accepted ISO control and relationship through which the requirement was reached. */
|
||||
via: Array<{ isoControlId: string; relationship: BsiCrosswalkRelationship }>;
|
||||
}
|
||||
|
||||
export interface BsiDerivation {
|
||||
crosswalkVersion: string;
|
||||
catalogVersion: string;
|
||||
requirements: BsiDerivedRequirement[];
|
||||
/** Mapping targets that the pinned Kernel catalog does not contain. They are reported, never reinterpreted. */
|
||||
missingTargets: Array<{ isoControlId: string; requirementId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 1 (user decision 2026-10-08): the Kernel requirements the official BSI mapping assigns to accepted ISO
|
||||
* controls. Derived only, never a decision of its own. Output is sorted, so the input order does not matter.
|
||||
*/
|
||||
export function deriveBsiRequirements(acceptedIsoControlIds: readonly string[], crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiDerivation {
|
||||
const accepted = new Set(acceptedIsoControlIds);
|
||||
const byId = new Map(catalog.requirements.map(item => [item.id, item]));
|
||||
const derived = new Map<string, BsiDerivedRequirement>();
|
||||
const missing = new Map<string, { isoControlId: string; requirementId: string }>();
|
||||
for (const entry of crosswalk.entries) {
|
||||
if (!accepted.has(entry.isoControlId)) continue;
|
||||
for (const target of entry.targets) {
|
||||
const requirement = byId.get(target);
|
||||
if (!requirement) {
|
||||
missing.set(`${entry.isoControlId}\u0000${target}`, { isoControlId: entry.isoControlId, requirementId: target });
|
||||
continue;
|
||||
}
|
||||
const item = derived.get(target) ?? { requirementId: target, title: requirement.title, practice: requirement.practice, via: [] };
|
||||
if (!item.via.some(via => via.isoControlId === entry.isoControlId && via.relationship === entry.relationship)) {
|
||||
item.via.push({ isoControlId: entry.isoControlId, relationship: entry.relationship });
|
||||
}
|
||||
derived.set(target, item);
|
||||
}
|
||||
}
|
||||
const order = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true });
|
||||
return {
|
||||
crosswalkVersion: crosswalk.version,
|
||||
catalogVersion: catalog.version,
|
||||
requirements: [...derived.values()]
|
||||
.map(item => ({ ...item, via: item.via.sort((a, b) => order(a.isoControlId, b.isoControlId) || a.relationship.localeCompare(b.relationship)) }))
|
||||
.sort((a, b) => order(a.requirementId, b.requirementId)),
|
||||
missingTargets: [...missing.values()].sort((a, b) => order(a.isoControlId, b.isoControlId) || order(a.requirementId, b.requirementId))
|
||||
};
|
||||
}
|
||||
|
||||
export interface BsiOverviewRequirement extends BsiDerivedRequirement {
|
||||
/** Statement with its parameters filled in, for display. */
|
||||
statement: string;
|
||||
modalVerb?: string;
|
||||
}
|
||||
|
||||
/** The derived BSI requirements a response carries, with the catalog provenance and attribution the UI shows. */
|
||||
export interface BsiOverview {
|
||||
catalogVersion: string;
|
||||
crosswalkVersion: string;
|
||||
license: string;
|
||||
/** Every practice of the catalog in catalog order, so the UI can order and title them without the catalog. */
|
||||
practices: Array<{ id: string; title: string }>;
|
||||
requirements: BsiOverviewRequirement[];
|
||||
missingTargets: BsiDerivation['missingTargets'];
|
||||
}
|
||||
|
||||
/** Stage 1 overview for accepted ISO controls; undefined when nothing is derived, so ISO-only responses stay unchanged. */
|
||||
export function bsiOverview(acceptedIsoControlIds: readonly string[], crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiOverview | undefined {
|
||||
const derivation = deriveBsiRequirements(acceptedIsoControlIds, crosswalk, catalog);
|
||||
if (!derivation.requirements.length && !derivation.missingTargets.length) return undefined;
|
||||
const byId = new Map(catalog.requirements.map(item => [item.id, item]));
|
||||
return {
|
||||
catalogVersion: catalog.version,
|
||||
crosswalkVersion: crosswalk.version,
|
||||
license: catalog.license,
|
||||
practices: catalog.practices,
|
||||
requirements: derivation.requirements.map(item => {
|
||||
const requirement = byId.get(item.requirementId)!;
|
||||
return { ...item, statement: bsiRequirementText(requirement.statement, requirement.params),
|
||||
...(requirement.modalVerb ? { modalVerb: requirement.modalVerb } : {}) };
|
||||
}),
|
||||
missingTargets: derivation.missingTargets
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The findings of each derived requirement: every finding of an accepted ISO control the requirement was
|
||||
* reached through, once per Result DB row, like the ISO theme counts.
|
||||
*/
|
||||
export function bsiRequirementFindings<F extends { resultProjectionId: string }>(
|
||||
controls: ReadonlyArray<{ controlId: string; findings: readonly F[] }>, overview: BsiOverview | undefined
|
||||
): Map<string, F[]> {
|
||||
const byControl = new Map(controls.map(control => [control.controlId, control.findings]));
|
||||
return new Map((overview?.requirements ?? []).map(requirement => {
|
||||
const findings = new Map<string, F>();
|
||||
for (const via of requirement.via) {
|
||||
for (const finding of byControl.get(via.isoControlId) ?? []) if (!findings.has(finding.resultProjectionId)) findings.set(finding.resultProjectionId, finding);
|
||||
}
|
||||
return [requirement.requirementId, [...findings.values()]];
|
||||
}));
|
||||
}
|
||||
|
||||
function optional<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
|
||||
return value ? { [key]: value } as Record<K, string> : {};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AiOverviewTokenUsage } from './ai-overview';
|
||||
import type { BsiOverview } from './bsi-grundschutz';
|
||||
import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag';
|
||||
import type { ResultDbFilters } from '../domain/result-db-query';
|
||||
import type { UnifiedResultProjection } from '../domain/unified-result';
|
||||
|
|
@ -228,6 +229,8 @@ export interface RegulatoryControlOverviewResponse {
|
|||
controls: RegulatoryControlOverviewItem[];
|
||||
/** Newest first, at most MAPPING_HISTORY_LIMIT; only on the Client Info overview. */
|
||||
mappingHistory?: RegulatoryMappingHistoryEntry[];
|
||||
/** BSI Kernel requirements derived from the accepted controls above; absent when none is derived. */
|
||||
bsi?: BsiOverview;
|
||||
}
|
||||
|
||||
export interface RegulatoryMappingResponse {
|
||||
|
|
@ -245,6 +248,8 @@ export interface RegulatoryMappingResponse {
|
|||
* version change), so an earlier accept stays reviewable. Titles come from older runs of the rule.
|
||||
*/
|
||||
earlierControls?: RegulatoryEarlierControl[];
|
||||
/** BSI Kernel requirements derived from this rule's accepted controls; absent when none is derived. */
|
||||
bsi?: BsiOverview;
|
||||
}
|
||||
|
||||
export interface RegulatoryEarlierControl {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||
import type { BsiCatalogState } from '../bsi-catalog-management';
|
||||
import { markRegulatoryDecisionSaved } from './decision-revision';
|
||||
|
||||
const api = new AssessorApiClient();
|
||||
|
||||
/** Data Hub BSI catalog tile, like the MITRE matrix tile; a switched-off Compliance Engine renders and requests nothing. */
|
||||
export function BsiCatalogLibrary() {
|
||||
return useEnginePreferences().compliance ? <BsiCatalogLibraryPanel/> : null;
|
||||
}
|
||||
|
||||
const day = (version: string) => version.slice(0, 10);
|
||||
|
||||
function BsiCatalogLibraryPanel() {
|
||||
const [status, setStatus] = useState<BsiCatalogState | null>(null);
|
||||
const [busy, setBusy] = useState<'refresh' | 'reset' | null>(null);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
// Any failure stays inside this tile; Data Hub keeps working.
|
||||
void (async () => {
|
||||
try { const value = await api.getBsiCatalog(); if (active) setStatus(value); }
|
||||
catch (reason) { if (active) setError(reason instanceof Error ? reason.message : 'The BSI catalog status could not be loaded.'); }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const run = async (action: 'refresh' | 'reset') => {
|
||||
setBusy(action); setFeedback(null); setError(null);
|
||||
try {
|
||||
if (action === 'refresh') {
|
||||
const result = await api.refreshBsiCatalog();
|
||||
setStatus(result);
|
||||
const missing = result.missingTargets.length ? ` ${result.missingTargets.length} mapping targets are not in the catalog.` : '';
|
||||
setFeedback(result.updated ? `Updated to catalog ${day(result.catalogVersion)} and mapping ${result.crosswalkVersion}.${missing}` : 'The BSI catalog is up to date.');
|
||||
if (result.updated) markRegulatoryDecisionSaved();
|
||||
} else {
|
||||
setStatus(await api.resetBsiCatalog());
|
||||
setFeedback('The bundled BSI catalog is active again.');
|
||||
markRegulatoryDecisionSaved();
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'The BSI catalog could not be updated.');
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
return <section className="datahub-rack-module datahub-reference-module bsi-catalog-library" data-bsi-catalog>
|
||||
<header className="datahub-rack-header">
|
||||
<span className="datahub-grip" aria-hidden="true">⠿</span>
|
||||
<i className="datahub-led" aria-hidden="true"/>
|
||||
<strong>BSI GRUNDSCHUTZ++ KERNEL</strong>
|
||||
<span className="datahub-rack-badge">{status
|
||||
? `${day(status.catalogVersion)} · Mapping ${status.crosswalkVersion} · ${status.origin === 'bsi' ? 'Updated' : 'Bundled'}`
|
||||
: 'Loading…'}</span>
|
||||
</header>
|
||||
<div className="datahub-rack-body">
|
||||
<div className="datahub-rack-actions">
|
||||
<button className="btn datahub-maintenance-action" type="button" disabled={busy !== null || !status} onClick={() => void run('refresh')}>
|
||||
{busy === 'refresh' ? 'Checking BSI…' : 'Update from BSI'}
|
||||
</button>
|
||||
{status?.origin === 'bsi' && <button className="btn" type="button" disabled={busy !== null} onClick={() => void run('reset')}>
|
||||
{busy === 'reset' ? 'Restoring…' : 'Use bundled catalog'}
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
{feedback && <div className="tenant-save-feedback success" role="status"><span aria-hidden="true">✓</span><strong>{feedback}</strong></div>}
|
||||
{error && <div className="datahub-alert" role="alert"><span>{error}</span></div>}
|
||||
</section>;
|
||||
}
|
||||
|
|
@ -6,11 +6,13 @@ import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/Cli
|
|||
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
||||
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
|
||||
import { useRegulatoryDecisionRevision } from './decision-revision';
|
||||
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
|
||||
import './compliance.css';
|
||||
|
||||
const assessorApi = new AssessorApiClient();
|
||||
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
||||
export const ISO_TILE_TITLE = 'ISO 27001/27002';
|
||||
export const BSI_TILE_TITLE = 'BSI Grundschutz++';
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
|
|
@ -21,6 +23,10 @@ interface Props {
|
|||
onOpenControl(controlId: string): void;
|
||||
/** Opens Work filtered to this ISO theme. */
|
||||
onOpenTheme(theme: RegulatoryControlTheme): void;
|
||||
/** Opens Work filtered to this derived BSI requirement. */
|
||||
onOpenBsiRequirement(requirementId: string): void;
|
||||
/** Opens Work filtered to this BSI practice. */
|
||||
onOpenBsiPractice(practiceId: string): void;
|
||||
/** The Client Info tab bar, shown above this tab like on the AI Overview. */
|
||||
navigation?: ReactNode;
|
||||
}
|
||||
|
|
@ -50,7 +56,7 @@ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
|
|||
* Client Info `Compliance` tab: one tile per framework in the environment tile geometry, the complete
|
||||
* control list in its Results window, and when the timeslot was last mapped. ISO/IEC 27001/27002 is the first framework.
|
||||
*/
|
||||
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, navigation }: Props) {
|
||||
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, navigation }: Props) {
|
||||
const available = useEnginePreferences().compliance;
|
||||
const revision = useRegulatoryDecisionRevision();
|
||||
const id = useId();
|
||||
|
|
@ -155,6 +161,7 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
|
|||
</header>
|
||||
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
|
||||
</section>
|
||||
<BsiTile state={state} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
|
|
@ -209,3 +216,83 @@ function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] })
|
|||
</div>
|
||||
</EnvironmentResultsButton>;
|
||||
}
|
||||
|
||||
const BSI_TILE_PRACTICES = 4;
|
||||
|
||||
interface BsiPracticeGroup { id: string; title: string; requirements: Array<BsiOverview['requirements'][number] & { findings: number }>; findings: number }
|
||||
|
||||
/** Practices in catalog order, only with derived requirements; a finding counts once per practice. */
|
||||
function bsiPracticeGroups(overview: RegulatoryControlOverviewResponse): { groups: BsiPracticeGroup[]; findings: number } {
|
||||
const bsi = overview.bsi;
|
||||
if (!bsi) return { groups: [], findings: 0 };
|
||||
const findingsOf = bsiRequirementFindings(overview.controls, bsi);
|
||||
const all = new Set<string>();
|
||||
const groups = bsi.practices.map(practice => {
|
||||
const practiceFindings = new Set<string>();
|
||||
const requirements = bsi.requirements.filter(requirement => requirement.practice === practice.id).map(requirement => {
|
||||
const findings = findingsOf.get(requirement.requirementId) ?? [];
|
||||
for (const finding of findings) { practiceFindings.add(finding.resultProjectionId); all.add(finding.resultProjectionId); }
|
||||
return { ...requirement, findings: findings.length };
|
||||
}).sort((left, right) => right.findings - left.findings || left.requirementId.localeCompare(right.requirementId, 'en', { numeric: true }));
|
||||
return { ...practice, requirements, findings: practiceFindings.size };
|
||||
}).filter(group => group.requirements.length > 0);
|
||||
return { groups, findings: all.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* BSI Grundschutz++ tile (Phase 13F, stage 1): Kernel requirements derived from the accepted ISO controls through
|
||||
* the BSI mapping. The four practices with the most findings follow the totals; the Results window lists all.
|
||||
*/
|
||||
function BsiTile({ state, onOpenRequirement, onOpenPractice }: { state: OverviewState; onOpenRequirement(id: string): void; onOpenPractice(id: string): void }) {
|
||||
const ready = state.status === 'ready';
|
||||
const { groups, findings } = ready ? bsiPracticeGroups(state.overview) : { groups: [], findings: 0 };
|
||||
const bsi = ready ? state.overview.bsi : undefined;
|
||||
const requirementCount = groups.reduce((total, group) => total + group.requirements.length, 0);
|
||||
const top = [...groups].sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES);
|
||||
const results = (dismiss: () => void) => {
|
||||
const open = (action: () => void) => { dismiss(); action(); };
|
||||
if (!groups.length || !bsi) return <p className="iso-controls__empty">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
|
||||
return <div className="iso-controls bsi-requirements">
|
||||
<p className="iso-controls__total" aria-label="Unique findings with a derived BSI requirement">Findings: {number(findings)}</p>
|
||||
<div className="iso-controls__themes" aria-label="Derived BSI requirements by practice">
|
||||
{groups.map(group => <div key={group.id} className="iso-controls__group" data-practice={group.id}>
|
||||
<button type="button" className="iso-controls__theme" aria-label={`Open BSI practice ${group.id} findings in Work (${group.findings})`}
|
||||
onClick={() => open(() => onOpenPractice(group.id))}>
|
||||
<span className="iso-controls__theme-name">{group.id} · {group.title}</span>
|
||||
<span className="iso-controls__theme-count">{count(group.requirements.length, 'requirement')} · {count(group.findings, 'finding')}</span>
|
||||
</button>
|
||||
<div className="iso-controls__rows">{group.requirements.map(requirement => <button key={requirement.requirementId} type="button" className="iso-controls__row"
|
||||
data-requirement={requirement.requirementId}
|
||||
aria-label={`Open ${requirement.requirementId} findings in Work (${requirement.findings})`}
|
||||
onClick={() => open(() => onOpenRequirement(requirement.requirementId))}>
|
||||
<span className="iso-controls__id">{requirement.requirementId}</span>
|
||||
<span className="iso-controls__title">{requirement.title}
|
||||
<span className="bsi-requirements__via"> · via {requirement.via.map(via => `A.${via.isoControlId}`).join(', ')}</span></span>
|
||||
<span className="iso-controls__amount">{number(requirement.findings)}</span>
|
||||
</button>)}</div>
|
||||
</div>)}
|
||||
</div>
|
||||
<p className="bsi-requirements__source">
|
||||
Derived from the accepted ISO controls through the BSI mapping ISO/IEC 27001 Annex A to Grundschutz++ {bsi.crosswalkVersion},
|
||||
catalog {bsi.catalogVersion.slice(0, 10)}. A requirement is reached when one of its ISO controls is accepted; it is no decision of its own.
|
||||
{bsi.missingTargets.length > 0 && ` Not in the catalog: ${[...new Set(bsi.missingTargets.map(item => item.requirementId))].join(', ')}.`} {bsi.license}
|
||||
</p>
|
||||
</div>;
|
||||
};
|
||||
return <section className="client-env-group" data-framework="bsi-grundschutz-plus-plus">
|
||||
<header className="client-env-group-header">
|
||||
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{BSI_TILE_TITLE}</span></h3>
|
||||
{ready && <EnvironmentResultsButton title={BSI_TILE_TITLE}>{results}</EnvironmentResultsButton>}
|
||||
</header>
|
||||
{state.status === 'error' ? <p role="alert" className="iso-controls__error">BSI requirements could not be loaded.</p>
|
||||
: <dl className="client-env-facts">
|
||||
<div><dt>Requirements</dt><dd>{ready ? number(requirementCount) : '—'}</dd></div>
|
||||
<div><dt>Findings</dt><dd>{ready ? number(findings) : '—'}</dd></div>
|
||||
{top.map(group => <div key={group.id} className="iso-tile-theme is-actionable" data-practice={group.id}>
|
||||
<dt>{group.title}</dt>
|
||||
<dd><button type="button" aria-label={`Open BSI practice ${group.id} findings in Work (${group.findings})`}
|
||||
onClick={() => onOpenPractice(group.id)}>{count(group.findings, 'finding')}</button></dd>
|
||||
</div>)}
|
||||
</dl>}
|
||||
</section>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||
import type { BsiOverview } from '../bsi-grundschutz';
|
||||
import './compliance.css';
|
||||
|
||||
const byNumber = (left: string, right: string) => left.localeCompare(right, 'en', { numeric: true });
|
||||
|
||||
/**
|
||||
* Finding Reader section of the BSI Grundschutz++ requirements derived from this finding's accepted ISO
|
||||
* controls through the BSI mapping (Phase 13F, stage 1). Derived only; it is no decision of its own.
|
||||
* User decision 2026-10-08: it starts collapsed like `Context`, groups the requirements under their ISO
|
||||
* control as one-line rows and shows a statement only when its requirement is opened.
|
||||
*/
|
||||
export function FindingBsiRequirements({ bsi }: { bsi?: BsiOverview }) {
|
||||
if (!bsi?.requirements.length) return null;
|
||||
const isoControls = [...new Set(bsi.requirements.flatMap(requirement => requirement.via.map(via => via.isoControlId)))].sort(byNumber);
|
||||
const count = bsi.requirements.length;
|
||||
return <details className="finding-context finding-bsi-requirements" data-bsi-requirements>
|
||||
<summary className="finding-context__header"><TriangleIcon direction="right"/><h4>BSI requirements</h4>
|
||||
<span className="finding-context__meta">{count} {count === 1 ? 'requirement' : 'requirements'} derived from the accepted ISO controls</span></summary>
|
||||
<div className="finding-context__content">
|
||||
{isoControls.map(isoControlId => {
|
||||
const reached = bsi.requirements.filter(requirement => requirement.via.some(via => via.isoControlId === isoControlId));
|
||||
// One BSI mapping entry per ISO control, so its relationship holds for the whole group.
|
||||
const relationship = reached[0].via.find(via => via.isoControlId === isoControlId)!.relationship;
|
||||
return <div key={isoControlId} className="finding-bsi-requirements__group" data-iso-control={isoControlId}>
|
||||
<p className="finding-bsi-requirements__route">A.{isoControlId} · {relationship}</p>
|
||||
<ul>{reached.map(requirement => <li key={requirement.requirementId} data-requirement={requirement.requirementId}>
|
||||
<details className="finding-bsi-requirements__item">
|
||||
<summary><TriangleIcon direction="right"/><strong>{requirement.requirementId}</strong><span>{requirement.title}</span></summary>
|
||||
<p className="finding-bsi-requirements__statement">{requirement.statement}</p>
|
||||
</details>
|
||||
</li>)}</ul>
|
||||
</div>;
|
||||
})}
|
||||
<p className="finding-bsi-requirements__source">
|
||||
Via BSI mapping {bsi.crosswalkVersion} · catalog {bsi.catalogVersion.slice(0, 10)} · Source: BSI Stand-der-Technik-Bibliothek, CC BY-SA 4.0
|
||||
</p>
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
|
|
@ -11,12 +11,14 @@ import { useRegulatoryDecisionRevision } from './decision-revision';
|
|||
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
||||
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
||||
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
|
||||
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID] as const;
|
||||
export const BSI_REQUIREMENT_FACET_ID = 'compliance:bsi-requirement';
|
||||
export const BSI_PRACTICE_FACET_ID = 'compliance:bsi-practice';
|
||||
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, BSI_REQUIREMENT_FACET_ID, BSI_PRACTICE_FACET_ID] as const;
|
||||
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
||||
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
||||
|
||||
export interface ComplianceWorkFacetIndex {
|
||||
facets: [DynamicFacet, DynamicFacet];
|
||||
facets: DynamicFacet[];
|
||||
}
|
||||
|
||||
const indexes = new WeakMap<RegulatoryControlOverviewResponse, ComplianceWorkFacetIndex>();
|
||||
|
|
@ -41,6 +43,14 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
|
|||
return identity ? [...bySetting.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
|
||||
};
|
||||
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
|
||||
// BSI requirements derived from the accepted ISO controls through the BSI mapping (Phase 13F, stage 1).
|
||||
const bsi = response.bsi;
|
||||
const requirementsByControl = new Map<string, string[]>();
|
||||
for (const requirement of bsi?.requirements ?? []) {
|
||||
for (const via of requirement.via) requirementsByControl.set(via.isoControlId, [...requirementsByControl.get(via.isoControlId) ?? [], requirement.requirementId]);
|
||||
}
|
||||
const practiceOf = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement.practice]));
|
||||
const requirementsFor = (record: FindingRecord): string[] => [...new Set(controlsFor(record).flatMap(controlId => requirementsByControl.get(controlId) ?? []))];
|
||||
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
|
||||
const index: ComplianceWorkFacetIndex = {
|
||||
facets: [{
|
||||
|
|
@ -61,13 +71,34 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
|
|||
values: THEMES.filter(theme => response.controls.some(control => control.theme === theme))
|
||||
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
|
||||
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
|
||||
}]
|
||||
},
|
||||
// The BSI facets appear only when a requirement is derived, so a client without one gets no empty controls.
|
||||
...(bsi?.requirements.length ? [{
|
||||
id: BSI_REQUIREMENT_FACET_ID,
|
||||
label: 'BSI requirement',
|
||||
source: 'derived',
|
||||
kind: 'category',
|
||||
description: 'Compliance Engine: findings and GPO settings with a BSI Grundschutz++ requirement derived from their accepted ISO controls through the BSI mapping. It does not change results.',
|
||||
// Values are matched in lower case, like the ISO theme; the label keeps the BSI spelling.
|
||||
values: bsi.requirements.map(requirement => ({ value: requirement.requirementId.toLowerCase(), label: `${requirement.requirementId} · ${requirement.title}`, count: 0 })),
|
||||
valuesFor: record => requirementsFor(record).map(requirementId => requirementId.toLowerCase())
|
||||
}, {
|
||||
id: BSI_PRACTICE_FACET_ID,
|
||||
label: 'BSI practice',
|
||||
source: 'derived',
|
||||
kind: 'category',
|
||||
description: 'Compliance Engine: findings and GPO settings with a derived BSI Grundschutz++ requirement of this practice. It does not change results.',
|
||||
// Catalog order; values are matched in lower case, the label keeps the BSI spelling.
|
||||
values: bsi.practices.filter(practice => bsi.requirements.some(requirement => requirement.practice === practice.id))
|
||||
.map(practice => ({ value: practice.id.toLowerCase(), label: `${practice.id} · ${practice.title}`, count: 0 })),
|
||||
valuesFor: (record: FindingRecord) => [...new Set(requirementsFor(record).flatMap(requirementId => practiceOf.get(requirementId)?.toLowerCase() ?? []))]
|
||||
}] satisfies DynamicFacet[] : [])]
|
||||
};
|
||||
indexes.set(response, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Adds the ISO facets directly before the permanent final `Source: Scan` control. */
|
||||
/** Adds the ISO and BSI facets directly before the permanent final `Source: Scan` control. */
|
||||
export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWorkFacetIndex | undefined): DynamicFacet[] {
|
||||
if (!index) return facets;
|
||||
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@
|
|||
.finding-iso-controls ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
|
||||
.finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; }
|
||||
.finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||
/* BSI requirements derived through the BSI mapping. Client Info Results: the ISO route and source stay readable. */
|
||||
.bsi-requirements__via, .bsi-requirements__source { font-size: var(--type-table-body-size); color: var(--text-secondary); overflow-wrap: break-word; }
|
||||
.bsi-requirements__source { margin: var(--space-3) 0 0; }
|
||||
/* Finding Reader: collapsed like Context, one group per ISO control, one line per requirement; a statement opens on demand. */
|
||||
.finding-bsi-requirements__group { display: grid; gap: var(--space-1); min-width: 0; }
|
||||
.finding-bsi-requirements__route, .finding-bsi-requirements__source { margin: 0; font-size: var(--type-table-body-size); color: var(--text-secondary); font-variant-numeric: tabular-nums; overflow-wrap: break-word; }
|
||||
.finding-bsi-requirements__group ul { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
|
||||
.finding-bsi-requirements__item > summary { display: flex; align-items: baseline; gap: var(--space-2); cursor: pointer; list-style: none; font-size: var(--type-body-size); line-height: 1.5; }
|
||||
.finding-bsi-requirements__item > summary::-webkit-details-marker { display: none; }
|
||||
.finding-bsi-requirements__item > summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
||||
.finding-bsi-requirements__item > summary strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.finding-bsi-requirements__statement { margin: var(--space-1) 0 var(--space-2) var(--space-5); font-size: var(--type-body-size); line-height: 1.5; }
|
||||
|
||||
/* Client Info `Compliance` tab; its tiles use the environment tile geometry. */
|
||||
.client-compliance-page { align-content: start; }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
// Counts saved control decisions in this browser session, so views that summarize accepted
|
||||
// controls (the Client Info overview) reload after a decision made elsewhere.
|
||||
// controls (the Client Info overview) reload after a decision made elsewhere. A change of the active
|
||||
// BSI catalog counts too, because the derived BSI requirements follow it.
|
||||
let revision = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ export function useRegulatoryMapping(
|
|||
decide,
|
||||
acceptedControls,
|
||||
acceptedCount: acceptedControls.length,
|
||||
/** BSI requirements derived from the accepted controls; undefined while the engine is off or none is derived. */
|
||||
bsi: available ? response?.bsi : undefined,
|
||||
disabledReason: localDisabledReason
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
|
||||
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
||||
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
|
||||
import { ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
|
||||
import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
|
||||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||||
import { useAssessmentContext } from '../context-layer/use-assessment-context';
|
||||
import { publishContextReport } from '../../assessment-context/context-report-publication';
|
||||
|
|
@ -1513,6 +1513,22 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
setStatusFilter('deviation');
|
||||
}
|
||||
setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] });
|
||||
}, false)}
|
||||
onOpenBsiRequirement={requirementId => jump(() => {
|
||||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||
if (targetSheet) {
|
||||
app.setActiveSheet(targetSheet.id);
|
||||
setStatusFilter('deviation');
|
||||
}
|
||||
setFacetFilter(BSI_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] });
|
||||
}, false)}
|
||||
onOpenBsiPractice={practiceId => jump(() => {
|
||||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||
if (targetSheet) {
|
||||
app.setActiveSheet(targetSheet.id);
|
||||
setStatusFilter('deviation');
|
||||
}
|
||||
setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] });
|
||||
}, false)}/>;
|
||||
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
|
||||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { maesterExecutionErrorText, maesterFindingTitle, maesterTagText } from '
|
|||
import type { BackendStatus } from '../../app/app-state';
|
||||
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
|
||||
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
|
||||
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
|
||||
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -555,6 +556,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)}
|
||||
{/* Compliance Engine: controls accepted for this finding, read like Description. */}
|
||||
<FindingIsoControls controls={regulatoryMapping.acceptedControls}/>
|
||||
{/* Compliance Engine: BSI requirements derived from those controls through the BSI mapping. */}
|
||||
<FindingBsiRequirements bsi={regulatoryMapping.bsi}/>
|
||||
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
|
||||
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
|
||||
{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadi
|
|||
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
|
||||
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
|
||||
import { IsoCorpusLibrary } from '../../compliance/ui/IsoCorpusLibrary';
|
||||
import { BsiCatalogLibrary } from '../../compliance/ui/BsiCatalogLibrary';
|
||||
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
|
||||
import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
|
||||
import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
|
||||
|
|
@ -1001,6 +1002,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
|||
<div className="datahub-reference-row">
|
||||
<MicrosoftBaselineLibrary/>
|
||||
<IsoCorpusLibrary/>
|
||||
<BsiCatalogLibrary/>
|
||||
<MitreMatrixLibrary
|
||||
status={mitreStatus}
|
||||
activeVersion={mitreActiveVersion}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@
|
|||
.datahub-reference-row > .mitre-library { grid-area: 1 / 1 / 2 / -1; }
|
||||
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1 / 3 / -1; margin-top: 0; }
|
||||
.datahub-reference-row > .iso-corpus-catalog { grid-area: 3 / 1 / 4 / -1; }
|
||||
.datahub-reference-row > .bsi-catalog-library { grid-area: 4 / 1 / 5 / -1; }
|
||||
.iso-corpus-content { display: grid; gap: var(--space-4); padding: var(--space-5); }
|
||||
.iso-corpus-summary { display: grid; gap: var(--space-2); color: var(--text-primary); font-size: var(--type-table-body-size); }
|
||||
.iso-corpus-summary strong { font-weight: var(--fw-semibold); }
|
||||
|
|
@ -143,6 +144,7 @@
|
|||
.datahub-reference-row > .mitre-library { grid-area: 1 / 1; }
|
||||
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1; }
|
||||
.datahub-reference-row > .iso-corpus-catalog { grid-area: 3 / 1; }
|
||||
.datahub-reference-row > .bsi-catalog-library { grid-area: 4 / 1; }
|
||||
.tenant-timeslot-toolbar, .tenant-timeslot-create { grid-template-columns: 1fr; }
|
||||
.datahub-rack-header { grid-template-columns: 20px 10px minmax(0, 1fr) auto; }
|
||||
.datahub-rack-actions { justify-content: stretch; }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
|||
|
||||
describe('ASSESSOR version in the brand header', () => {
|
||||
it('shows the centrally maintained product version and opens About from the brand', () => {
|
||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.21.0');
|
||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.22.0');
|
||||
expect(app).toContain("'db-structure': 'Data Flow'");
|
||||
expect(app).toContain("['db-structure', 'Data Flow']");
|
||||
expect(app).not.toContain("'DB Structure'");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import type { Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createAssessorHttpServer } from '../../server/assessor-http-server';
|
||||
import { BsiCatalogError, type BsiCatalogService } from '../../server/compliance/bsi-catalog-service';
|
||||
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
|
||||
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
||||
|
||||
// Data Hub `Update from BSI` routes; the service is a stub with synthetic states.
|
||||
const bundled: BsiCatalogState = { version: 1, origin: 'bundled', catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic',
|
||||
requirementCount: 2, crosswalkEntryCount: 1, catalogSha256: 'a'.repeat(64), crosswalkSha256: 'b'.repeat(64), missingTargets: [] };
|
||||
const updated: BsiCatalogState = { ...bundled, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: '2099-01-01T00:00:00+00:00' };
|
||||
|
||||
describe('BSI catalog routes', () => {
|
||||
let directory: string;
|
||||
let server: Server | undefined;
|
||||
let baseUrl: string;
|
||||
const service = { current: vi.fn(), refresh: vi.fn(), reset: vi.fn() };
|
||||
|
||||
const start = async (bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>) => {
|
||||
server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, bsiCatalogService });
|
||||
await new Promise<void>((resolve, reject) => { server!.once('error', reject); server!.listen(0, '127.0.0.1', resolve); });
|
||||
baseUrl = `http://127.0.0.1:${(server!.address() as AddressInfo).port}/api/v1/reference-data/bsi-grundschutz`;
|
||||
};
|
||||
const call = (suffix = '', method = 'GET') => fetch(`${baseUrl}${suffix}`, method === 'GET' ? {} : { method, headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: new URL(baseUrl).origin }, body: '{}' });
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'assessor-bsi-http-'));
|
||||
await writeFile(join(directory, 'index.html'), '<main>synthetic app</main>');
|
||||
service.current.mockResolvedValue(bundled);
|
||||
service.refresh.mockResolvedValue({ ...updated, updated: true });
|
||||
service.reset.mockResolvedValue(bundled);
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (server) await new Promise<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
|
||||
server = undefined;
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads, updates and resets the active catalog', async () => {
|
||||
await start(service as never);
|
||||
const read = await call();
|
||||
expect(read.status).toBe(200);
|
||||
expect(await read.json()).toEqual(bundled);
|
||||
const refreshed = await call('/refresh', 'POST');
|
||||
expect(refreshed.status).toBe(200);
|
||||
expect(await refreshed.json()).toEqual({ ...updated, updated: true });
|
||||
const reset = await call('/reset', 'POST');
|
||||
expect(await reset.json()).toEqual(bundled);
|
||||
expect([service.current.mock.calls.length, service.refresh.mock.calls.length, service.reset.mock.calls.length]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('reports a BSI failure as a bad gateway with its code and an unconfigured service as unavailable', async () => {
|
||||
service.refresh.mockRejectedValue(new BsiCatalogError('bsi_unreachable', 'BSI could not be reached. Please try again later.'));
|
||||
await start(service as never);
|
||||
const failed = await call('/refresh', 'POST');
|
||||
expect(failed.status).toBe(502);
|
||||
expect(await failed.json()).toMatchObject({ error: { code: 'bsi_unreachable', message: 'BSI could not be reached. Please try again later.' } });
|
||||
await new Promise<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
|
||||
await start();
|
||||
const unavailable = await call();
|
||||
expect(unavailable.status).toBe(503);
|
||||
expect(await unavailable.json()).toMatchObject({ error: { code: 'bsi_catalog_unavailable' } });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// @vitest-environment jsdom
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||||
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
|
||||
import { BsiCatalogLibrary } from '../../src/compliance/ui/BsiCatalogLibrary';
|
||||
import { useRegulatoryDecisionRevision } from '../../src/compliance/ui/decision-revision';
|
||||
|
||||
// Data Hub BSI tile (user decision 2026-10-08): like the MITRE matrix tile, it names the active catalog and
|
||||
// mapping and offers `Update from BSI`; `Use bundled catalog` appears only after an update. Synthetic states.
|
||||
const bundled: BsiCatalogState = { version: 1, origin: 'bundled', catalogVersion: '2026-09-24T07:12:39.131983+00:00', crosswalkVersion: '1.0.1-qa',
|
||||
requirementCount: 901, crosswalkEntryCount: 83, catalogSha256: 'a'.repeat(64), crosswalkSha256: 'b'.repeat(64), missingTargets: ['BER.6.8'] };
|
||||
const updated: BsiCatalogState = { ...bundled, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: '2026-10-01T00:00:00+00:00',
|
||||
crosswalkVersion: '1.0.2', missingTargets: ['BER.6.8', 'GEB.5.4'] };
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let revisions: number[];
|
||||
function Revision() { revisions.push(useRegulatoryDecisionRevision()); return null; }
|
||||
const render = () => act(async () => {
|
||||
root.render(<><BsiCatalogLibrary/><Revision/></>);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
const badge = () => host.querySelector('[data-bsi-catalog] .datahub-rack-badge')?.textContent;
|
||||
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>('[data-bsi-catalog] button')].find(item => item.textContent === label);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
localStorage.clear();
|
||||
resetEnginePreferencesForTests();
|
||||
revisions = [];
|
||||
host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
resetEnginePreferencesForTests();
|
||||
});
|
||||
|
||||
describe('Data Hub BSI catalog tile', () => {
|
||||
it('names the bundled catalog and mapping, updates from BSI and reloads the derived BSI views', async () => {
|
||||
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
|
||||
const refresh = vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockResolvedValue({ ...updated, updated: true });
|
||||
await render();
|
||||
expect(host.querySelector('[data-bsi-catalog] .datahub-rack-header strong')?.textContent).toBe('BSI GRUNDSCHUTZ++ KERNEL');
|
||||
expect(badge()).toBe('2026-09-24 · Mapping 1.0.1-qa · Bundled');
|
||||
expect(button('Use bundled catalog')).toBeUndefined();
|
||||
const before = revisions.at(-1)!;
|
||||
await act(async () => button('Update from BSI')!.click());
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(badge()).toBe('2026-10-01 · Mapping 1.0.2 · Updated');
|
||||
expect(host.querySelector('[role="status"]')?.textContent).toBe('✓Updated to catalog 2026-10-01 and mapping 1.0.2. 2 mapping targets are not in the catalog.');
|
||||
// Client Info and Work reload their derived BSI requirements.
|
||||
expect(revisions.at(-1)).toBe(before + 1);
|
||||
expect(button('Use bundled catalog')).toBeDefined();
|
||||
});
|
||||
|
||||
it('says when the catalog is up to date without reloading other views', async () => {
|
||||
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
|
||||
vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockResolvedValue({ ...bundled, updated: false });
|
||||
await render();
|
||||
const before = revisions.at(-1);
|
||||
await act(async () => button('Update from BSI')!.click());
|
||||
expect(host.querySelector('[role="status"]')?.textContent).toBe('✓The BSI catalog is up to date.');
|
||||
expect(revisions.at(-1)).toBe(before);
|
||||
});
|
||||
|
||||
it('returns to the bundled catalog and shows an update failure without changing the badge', async () => {
|
||||
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(updated);
|
||||
const reset = vi.spyOn(AssessorApiClient.prototype, 'resetBsiCatalog').mockResolvedValue(bundled);
|
||||
vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockRejectedValue(new Error('BSI could not be reached. Please try again later.'));
|
||||
await render();
|
||||
await act(async () => button('Update from BSI')!.click());
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toBe('BSI could not be reached. Please try again later.');
|
||||
expect(badge()).toBe('2026-10-01 · Mapping 1.0.2 · Updated');
|
||||
await act(async () => button('Use bundled catalog')!.click());
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(badge()).toBe('2026-09-24 · Mapping 1.0.1-qa · Bundled');
|
||||
expect(host.querySelector('[role="status"]')?.textContent).toBe('✓The bundled BSI catalog is active again.');
|
||||
expect(button('Use bundled catalog')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders and requests nothing while the Compliance Engine is off', async () => {
|
||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
|
||||
setEngineEnabled('compliance', false);
|
||||
await render();
|
||||
expect(host.querySelector('[data-bsi-catalog]')).toBeNull();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sits in the Data Hub reference row directly after the ISO corpus tile', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/features/project-import/DataHub.tsx'), 'utf8');
|
||||
expect(source).toContain(' <IsoCorpusLibrary/>\n <BsiCatalogLibrary/>\n');
|
||||
const styles = readFileSync(join(process.cwd(), 'src/styles/datahub-redesign.css'), 'utf8');
|
||||
expect(styles).toContain('.datahub-reference-row > .bsi-catalog-library { grid-area: 4 / 1 / 5 / -1; }');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BSI_KERNEL_CLASS } from '../../src/compliance/bsi-grundschutz';
|
||||
import { BSI_CATALOG_SOURCE_URL, BSI_CROSSWALK_SOURCE_URL, BsiCatalogService } from '../../server/compliance/bsi-catalog-service';
|
||||
import { bundledBsiCatalogs } from '../../server/compliance/bsi-snapshots';
|
||||
|
||||
// `Update from BSI` (Phase 13F, user decision 2026-10-08): synthetic OSCAL in the published shape; ids and prose are invented.
|
||||
const sourceCatalog = (version: string, ids = ['KONF.1.1', 'KONF.2.1']) => JSON.stringify({ catalog: {
|
||||
metadata: { title: 'Synthetischer Katalog', version },
|
||||
groups: [{ id: 'KONF', title: 'Konfiguration', controls: ids.map(id => ({ id, class: BSI_KERNEL_CLASS, title: `Titel ${id}`,
|
||||
parts: [{ name: 'statement', prose: `Konfiguration MUSS ${id} umsetzen.` }] })) }]
|
||||
} });
|
||||
const sourceCrosswalk = (version = '9.9-synthetic', targets = ['KONF.1.1', 'KONF.3.1']) => JSON.stringify({ 'mapping-collection': {
|
||||
metadata: { title: 'Synthetische Zuordnung', version },
|
||||
mappings: [{ 'target-resource': { href: 'BSI-Stand-der-Technik-Kernel-catalog.json' },
|
||||
maps: [{ uuid: 'm1', relationship: 'subset-of', sources: [{ 'id-ref': '8.9' }], targets: targets.map(target => ({ 'id-ref': target })) }] }]
|
||||
} });
|
||||
const NEWER = '2099-01-01T00:00:00+00:00';
|
||||
|
||||
let directory: string;
|
||||
let storage: string;
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'assessor-bsi-'));
|
||||
storage = join(directory, 'bsi', 'active-catalog.json');
|
||||
});
|
||||
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
|
||||
|
||||
function fetcher(files: Record<string, string | Response>) {
|
||||
return vi.fn(async (url: string | URL | Request) => {
|
||||
const body = files[String(url)];
|
||||
return body instanceof Response ? body : new Response(body, { status: 200 });
|
||||
}) as unknown as typeof fetch & ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
describe('BSI catalog service', () => {
|
||||
it('starts with the bundled snapshots', async () => {
|
||||
const service = new BsiCatalogService(storage, fetcher({}));
|
||||
expect(await service.catalogs()).toBe(bundledBsiCatalogs);
|
||||
expect(await service.current()).toMatchObject({ version: 1, origin: 'bundled', catalogVersion: bundledBsiCatalogs.catalog.version,
|
||||
crosswalkVersion: '1.0.1-qa', requirementCount: 901, missingTargets: ['BER.5.1.2', 'BER.6.8', 'DET.3.1.13', 'GEB.5.4'] });
|
||||
});
|
||||
|
||||
it('stores a newer official state privately, activates it at once and reports it unchanged on the next update', async () => {
|
||||
const fetch = fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() });
|
||||
const service = new BsiCatalogService(storage, fetch, () => new Date('2026-10-08T12:00:00Z'));
|
||||
const result = await service.refresh();
|
||||
expect(result).toMatchObject({ updated: true, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: NEWER,
|
||||
crosswalkVersion: '9.9-synthetic', requirementCount: 2, crosswalkEntryCount: 1, missingTargets: ['KONF.3.1'] });
|
||||
// Only the fixed official URLs, without following a redirect.
|
||||
expect(fetch.mock.calls.map(call => [String(call[0]), (call[1] as RequestInit).redirect])).toEqual([
|
||||
[BSI_CATALOG_SOURCE_URL, 'error'], [BSI_CROSSWALK_SOURCE_URL, 'error']]);
|
||||
expect((await service.catalogs()).catalog.requirements.map(item => item.id)).toEqual(['KONF.1.1', 'KONF.2.1']);
|
||||
expect((await service.catalogs()).catalog.sourceUrl).toBe(BSI_CATALOG_SOURCE_URL);
|
||||
if (process.platform !== 'win32') expect((await stat(storage)).mode & 0o777).toBe(0o600);
|
||||
// A restarted server reads the stored state.
|
||||
expect(await new BsiCatalogService(storage, fetcher({})).current()).toMatchObject({ origin: 'bsi', catalogVersion: NEWER });
|
||||
expect(await service.refresh()).toMatchObject({ updated: false, catalogVersion: NEWER });
|
||||
});
|
||||
|
||||
it('never steps back behind the active catalog', async () => {
|
||||
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog('2000-01-01T00:00:00+00:00'), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
|
||||
expect(await service.refresh()).toMatchObject({ updated: false, origin: 'bundled' });
|
||||
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('fails explicitly and keeps the active catalog when BSI is unreachable, unavailable, too large or invalid', async () => {
|
||||
const cases: Array<[Record<string, string | Response> | null, string]> = [
|
||||
[null, 'bsi_unreachable'],
|
||||
[{ [BSI_CATALOG_SOURCE_URL]: new Response('', { status: 404 }), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_unavailable'],
|
||||
[{ [BSI_CATALOG_SOURCE_URL]: new Response('{}', { headers: { 'content-length': String(64 * 1024 * 1024) } }), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_source_too_large'],
|
||||
[{ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER, []), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_source_invalid'],
|
||||
[{ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: '{"mapping-collection":{}}' }, 'bsi_source_invalid']
|
||||
];
|
||||
for (const [files, code] of cases) {
|
||||
const download = files ? fetcher(files) : vi.fn(async () => { throw new TypeError("offline"); }) as unknown as typeof globalThis.fetch;
|
||||
const service = new BsiCatalogService(storage, download);
|
||||
await expect(service.refresh()).rejects.toMatchObject({ code });
|
||||
expect(await service.current()).toMatchObject({ origin: 'bundled' });
|
||||
}
|
||||
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('returns to the bundled snapshots on reset', async () => {
|
||||
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
|
||||
await service.refresh();
|
||||
expect(await service.reset()).toMatchObject({ origin: 'bundled', catalogVersion: bundledBsiCatalogs.catalog.version });
|
||||
expect(await service.catalogs()).toBe(bundledBsiCatalogs);
|
||||
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('refuses a damaged stored state and lets a stored state older than the bundled one give way to it', async () => {
|
||||
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
|
||||
await service.refresh();
|
||||
const stored = JSON.parse(await readFile(storage, 'utf8'));
|
||||
await writeFile(storage, JSON.stringify({ ...stored, catalog: { ...stored.catalog, framework: 'other' } }));
|
||||
await expect(new BsiCatalogService(storage, fetcher({})).current()).rejects.toMatchObject({ code: 'bsi_storage_invalid' });
|
||||
await writeFile(storage, JSON.stringify({ ...stored, catalog: { ...stored.catalog, version: '2000-01-01T00:00:00+00:00' } }));
|
||||
expect(await new BsiCatalogService(storage, fetcher({})).catalogs()).toBe(bundledBsiCatalogs);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BSI_KERNEL_CLASS, bsiIsoCrosswalk, bsiKernelCatalog, bsiRequirementText, deriveBsiRequirements
|
||||
} from '../../src/compliance/bsi-grundschutz';
|
||||
import pinnedCatalog from '../../server/compliance/data/bsi-grundschutz-kernel-2026-09-24.json';
|
||||
import pinnedCrosswalk from '../../server/compliance/data/bsi-iso27001-crosswalk-1.0.1-qa.json';
|
||||
import type { BsiIsoCrosswalk, BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz';
|
||||
|
||||
// Synthetic OSCAL in the published shape; ids, titles and prose are invented.
|
||||
const control = (id: string, extra: Record<string, unknown> = {}) => ({
|
||||
id, class: BSI_KERNEL_CLASS, title: `Titel ${id}`,
|
||||
props: [{ name: 'sec_level', value: 'normal-SdT' }, { name: 'effort_level', value: '2' }],
|
||||
parts: [
|
||||
{ id: `${id}_stm`, name: 'statement', props: [{ name: 'modal_verb', value: 'MUSS' }], prose: `Praktik MUSS ${id} umsetzen.` },
|
||||
{ id: `${id}_gdn`, name: 'guidance', prose: `Erlaeuterung zu ${id}.` }
|
||||
],
|
||||
...extra
|
||||
});
|
||||
|
||||
const syntheticCatalog = (controls: unknown[], groups: unknown[] = []) => ({
|
||||
catalog: {
|
||||
metadata: { title: 'Synthetischer Katalog', version: '2026-01-01T00:00:00+00:00' },
|
||||
groups: [
|
||||
{ id: 'KONF', title: 'Konfiguration', controls },
|
||||
{ id: 'GC', title: 'Governance', controls: [{ ...control('GC.1.1'), class: 'BSI-Methodik-Grundschutz-plus-plus' }] },
|
||||
...groups
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const syntheticMapping = (maps: unknown[]) => ({
|
||||
'mapping-collection': {
|
||||
metadata: { title: 'Synthetische Zuordnung', version: '0.1' },
|
||||
mappings: [
|
||||
{ 'target-resource': { href: 'BSI-Methodik-Grundschutz++-catalog.json' }, maps: [
|
||||
{ uuid: 'm0', relationship: 'subset-of', sources: [{ 'id-ref': '5.1' }], targets: [{ 'id-ref': 'GC.1.1' }] }] },
|
||||
{ 'target-resource': { href: 'BSI-Stand-der-Technik-Kernel-catalog.json' }, maps }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const map = (uuid: string, iso: string, relationship: string, targets: string[]) =>
|
||||
({ uuid, relationship, sources: [{ 'id-ref': iso }], targets: targets.map(target => ({ 'id-ref': target })) });
|
||||
|
||||
describe('BSI Kernel catalog conversion', () => {
|
||||
it('keeps only Kernel requirements with practice, parent, statement and parameters as published', () => {
|
||||
const catalog = bsiKernelCatalog(syntheticCatalog([
|
||||
control('KONF.1.1', { controls: [control('KONF.1.1.1', {
|
||||
params: [{ id: 'konf.1.1.1-prm1', label: 'regelmaessig' }],
|
||||
parts: [{ id: 's', name: 'statement', prose: 'Praktik MUSS {{ insert: param, konf.1.1.1-prm1 }} pruefen.' }]
|
||||
})] })
|
||||
]), 'https://example.test/catalog.json', 'a'.repeat(64));
|
||||
|
||||
expect(catalog.requirements.map(item => item.id)).toEqual(['KONF.1.1', 'KONF.1.1.1']);
|
||||
expect(catalog.practices).toEqual([{ id: 'KONF', title: 'Konfiguration' }]);
|
||||
expect(catalog.requirements[0]).toMatchObject({ practice: 'KONF', modalVerb: 'MUSS', guidance: 'Erlaeuterung zu KONF.1.1.', securityLevel: 'normal-SdT', effortLevel: '2' });
|
||||
const child = catalog.requirements[1];
|
||||
expect(child).toMatchObject({ parentId: 'KONF.1.1', statement: 'Praktik MUSS {{ insert: param, konf.1.1.1-prm1 }} pruefen.' });
|
||||
expect(child.guidance).toBeUndefined();
|
||||
expect(bsiRequirementText(child.statement, child.params)).toBe('Praktik MUSS regelmaessig pruefen.');
|
||||
expect(catalog).toMatchObject({ version: '2026-01-01T00:00:00+00:00', sourceSha256: 'a'.repeat(64) });
|
||||
expect(catalog.license).toContain('CC BY-SA 4.0');
|
||||
});
|
||||
|
||||
it('prefers a single published parameter value over its label', () => {
|
||||
const catalog = bsiKernelCatalog(syntheticCatalog([control('KONF.2.1', {
|
||||
params: [{ id: 'p1', label: 'Bezeichnung', values: ['jaehrlich'] }],
|
||||
parts: [{ name: 'statement', prose: 'Praktik MUSS {{ insert: param, p1 }} pruefen.' }]
|
||||
})]), 'u', 's');
|
||||
expect(bsiRequirementText(catalog.requirements[0].statement, catalog.requirements[0].params)).toBe('Praktik MUSS jaehrlich pruefen.');
|
||||
});
|
||||
|
||||
it('fails explicitly on malformed input instead of producing a partial catalog', () => {
|
||||
expect(() => bsiKernelCatalog({}, 'u', 's')).toThrow(/OSCAL Grundschutz\+\+ catalog/);
|
||||
expect(() => bsiKernelCatalog(syntheticCatalog([]), 'u', 's')).toThrow(/no Kernel requirements/);
|
||||
expect(() => bsiKernelCatalog(syntheticCatalog([control('KONF.1.1'), control('KONF.1.1')]), 'u', 's')).toThrow(/Repeated Kernel requirement ids: KONF.1.1/);
|
||||
expect(() => bsiKernelCatalog(syntheticCatalog([control('konf-1')]), 'u', 's')).toThrow(/Invalid Kernel requirement id/);
|
||||
expect(() => bsiKernelCatalog(syntheticCatalog([control('KONF.1.1', { parts: [{ name: 'guidance', prose: 'x' }] })]), 'u', 's')).toThrow(/no statement/);
|
||||
expect(() => bsiKernelCatalog(syntheticCatalog([control('KONF.1.1', {
|
||||
parts: [{ name: 'statement', prose: 'MUSS {{ insert: param, fehlt }}.' }]
|
||||
})]), 'u', 's')).toThrow(/unknown parameter fehlt/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BSI ISO 27001 crosswalk conversion', () => {
|
||||
it('keeps only the Kernel mapping with its relationships and targets as published', () => {
|
||||
const crosswalk = bsiIsoCrosswalk(syntheticMapping([map('m1', '8.9', 'subset-of', ['KONF.1.1', 'KONF.1.1.1'])]), 'u', 's');
|
||||
expect(crosswalk.targetCatalog).toBe('BSI-Stand-der-Technik-Kernel-catalog.json');
|
||||
expect(crosswalk.entries).toEqual([{ uuid: 'm1', isoControlId: '8.9', relationship: 'subset-of', targets: ['KONF.1.1', 'KONF.1.1.1'] }]);
|
||||
});
|
||||
|
||||
it('fails explicitly on unknown relationships, sources or targets', () => {
|
||||
expect(() => bsiIsoCrosswalk(syntheticMapping([map('m', '8.9', 'related-to', ['KONF.1.1'])]), 'u', 's')).toThrow(/Unknown mapping relationship/);
|
||||
expect(() => bsiIsoCrosswalk(syntheticMapping([map('m', 'A.8.9', 'subset-of', ['KONF.1.1'])]), 'u', 's')).toThrow(/ISO Annex A source/);
|
||||
expect(() => bsiIsoCrosswalk(syntheticMapping([map('m', '8.9', 'subset-of', [])]), 'u', 's')).toThrow(/Invalid mapping targets/);
|
||||
expect(() => bsiIsoCrosswalk({ 'mapping-collection': { metadata: { version: '1' }, mappings: [] } }, 'u', 's')).toThrow(/exactly one mapping to the Kernel catalog/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BSI requirement derivation from accepted ISO controls', () => {
|
||||
const catalog = bsiKernelCatalog(syntheticCatalog([control('KONF.1.1'), control('KONF.2.1'), control('KONF.10.1')]), 'u', 's');
|
||||
const crosswalk = bsiIsoCrosswalk(syntheticMapping([
|
||||
map('m1', '8.9', 'subset-of', ['KONF.10.1', 'KONF.1.1']),
|
||||
map('m2', '8.20', 'intersects-with', ['KONF.1.1', 'KONF.9.9']),
|
||||
map('m3', '5.15', 'equivalent-to', ['KONF.2.1'])
|
||||
]), 'u', 's');
|
||||
|
||||
it('derives Kernel requirements with every ISO route, reports missing targets and ignores other controls', () => {
|
||||
const derivation = deriveBsiRequirements(['8.20', '8.9'], crosswalk, catalog);
|
||||
expect(derivation.requirements).toEqual([
|
||||
{ requirementId: 'KONF.1.1', title: 'Titel KONF.1.1', practice: 'KONF', via: [
|
||||
{ isoControlId: '8.9', relationship: 'subset-of' }, { isoControlId: '8.20', relationship: 'intersects-with' }] },
|
||||
{ requirementId: 'KONF.10.1', title: 'Titel KONF.10.1', practice: 'KONF', via: [{ isoControlId: '8.9', relationship: 'subset-of' }] }
|
||||
]);
|
||||
expect(derivation.missingTargets).toEqual([{ isoControlId: '8.20', requirementId: 'KONF.9.9' }]);
|
||||
expect(derivation).toMatchObject({ crosswalkVersion: '0.1', catalogVersion: '2026-01-01T00:00:00+00:00' });
|
||||
});
|
||||
|
||||
it('is independent of input order and yields nothing without accepted controls', () => {
|
||||
expect(deriveBsiRequirements(['8.9', '8.20', '8.9'], crosswalk, catalog)).toEqual(deriveBsiRequirements(['8.20', '8.9'], crosswalk, catalog));
|
||||
expect(deriveBsiRequirements([], crosswalk, catalog)).toMatchObject({ requirements: [], missingTargets: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pinned BSI snapshots', () => {
|
||||
const catalog = pinnedCatalog as BsiKernelCatalog;
|
||||
const crosswalk = pinnedCrosswalk as BsiIsoCrosswalk;
|
||||
|
||||
it('carry provenance and the decided Kernel scope', () => {
|
||||
expect(catalog).toMatchObject({ framework: 'bsi-grundschutz-plus-plus', catalogClass: BSI_KERNEL_CLASS, version: '2026-09-24T07:12:39.131983+00:00' });
|
||||
expect(catalog.requirements).toHaveLength(901);
|
||||
expect(new Set(catalog.requirements.map(item => item.id)).size).toBe(901);
|
||||
expect(catalog.sourceUrl).toMatch(/^https:\/\/raw\.githubusercontent\.com\/BSI-Bund\/Stand-der-Technik-Bibliothek\/[0-9a-f]{40}\//);
|
||||
expect(catalog.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(crosswalk).toMatchObject({ version: '1.0.1-qa', targetCatalog: 'BSI-Stand-der-Technik-Kernel-catalog.json' });
|
||||
expect(crosswalk.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('report exactly the mapping targets the pinned catalog no longer contains', () => {
|
||||
const allIso = [...new Set(crosswalk.entries.map(entry => entry.isoControlId))];
|
||||
const missing = deriveBsiRequirements(allIso, crosswalk, catalog).missingTargets.map(item => item.requirementId);
|
||||
expect([...new Set(missing)].sort()).toEqual(['BER.5.1.2', 'BER.6.8', 'DET.3.1.13', 'GEB.5.4']);
|
||||
});
|
||||
});
|
||||
|
|
@ -35,9 +35,12 @@ let host: HTMLDivElement;
|
|||
let root: Root;
|
||||
const onOpenControl = vi.fn();
|
||||
const onOpenTheme = vi.fn();
|
||||
const onOpenBsiRequirement = vi.fn();
|
||||
const onOpenBsiPractice = vi.fn();
|
||||
const render = () => act(async () => {
|
||||
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
|
||||
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}/>);
|
||||
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
|
||||
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice}/>);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
|
|
@ -48,6 +51,8 @@ beforeEach(() => {
|
|||
resetClientComplianceCacheForTests();
|
||||
onOpenControl.mockReset();
|
||||
onOpenTheme.mockReset();
|
||||
onOpenBsiRequirement.mockReset();
|
||||
onOpenBsiPractice.mockReset();
|
||||
host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
|
@ -160,6 +165,10 @@ describe('Client Info ISO controls', () => {
|
|||
expect(drilldown).toContain("setStatusFilter('deviation')");
|
||||
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
|
||||
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
|
||||
// The BSI tile opens Work the same way through the lower-case BSI facet values.
|
||||
expect(drilldown).toContain('setFacetFilter(BSI_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] })');
|
||||
expect(drilldown).toContain('setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] })');
|
||||
expect(drilldown.match(/setStatusFilter\('deviation'\)/g)).toHaveLength(4);
|
||||
expect(drilldown).not.toContain('setPathFindingIds');
|
||||
// A scanner finding resolves to its Work record through the occurrence id.
|
||||
const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true });
|
||||
|
|
@ -236,3 +245,80 @@ describe('Client Info ISO controls', () => {
|
|||
expect(facts()[0]).toEqual(['Controls', '1']);
|
||||
});
|
||||
});
|
||||
|
||||
// BSI Grundschutz++ tile (Phase 13F, stage 1): requirements derived from accepted ISO controls through the BSI
|
||||
// mapping. Synthetic requirement ids, titles and statements; the findings come from the ISO controls above.
|
||||
const bsi: NonNullable<RegulatoryControlOverviewResponse['bsi']> = {
|
||||
catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution (CC BY-SA 4.0).',
|
||||
practices: [{ id: 'BER', title: 'Berechtigung' }, { id: 'ARCH', title: 'Architektur' }, { id: 'KONF', title: 'Konfiguration' }, { id: 'DET', title: 'Detektion' }],
|
||||
requirements: [
|
||||
{ requirementId: 'ARCH.1.1', title: 'Synthetische Architektur', practice: 'ARCH', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] },
|
||||
{ requirementId: 'BER.1.1', title: 'Synthetische Berechtigung', practice: 'BER', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'equivalent-to' }] },
|
||||
{ requirementId: 'KONF.1.1', title: 'Synthetische Konfiguration', practice: 'KONF', statement: 'Synthetisch.',
|
||||
via: [{ isoControlId: '8.20', relationship: 'intersects-with' }, { isoControlId: '8.9', relationship: 'subset-of' }] },
|
||||
{ requirementId: 'KONF.2.1', title: 'Zweite Konfiguration', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] }
|
||||
],
|
||||
missingTargets: [{ isoControlId: '8.9', requirementId: 'KONF.9.9' }]
|
||||
};
|
||||
|
||||
describe('Client Info BSI Grundschutz++ tile', () => {
|
||||
const tile = () => host.querySelector<HTMLElement>('[data-framework="bsi-grundschutz-plus-plus"]');
|
||||
const facts = () => [...tile()!.querySelectorAll('.client-env-facts > div')].map(fact => [fact.querySelector('dt')?.textContent, fact.querySelector('dd')?.textContent]);
|
||||
const openResults = () => act(async () => tile()!.querySelector<HTMLButtonElement>('.client-environment-results-button')!.click());
|
||||
const groups = () => [...document.querySelectorAll<HTMLElement>('.bsi-requirements .iso-controls__group')];
|
||||
|
||||
it('sits beside the ISO tile with totals and the practices with the most findings, and opens Work from a practice', async () => {
|
||||
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi });
|
||||
await render();
|
||||
expect([...host.querySelectorAll<HTMLElement>('.client-compliance-grid > section.client-env-group')].map(section => section.dataset.framework))
|
||||
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus']);
|
||||
expect(tile()!.querySelector('.client-env-group-title')?.textContent).toBe('BSI Grundschutz++');
|
||||
// KONF.1.1 reaches all three findings through 8.9; a finding counts once per practice and once in total.
|
||||
expect(facts()).toEqual([['Requirements', '4'], ['Findings', '3'], ['Konfiguration', '3 findings'], ['Architektur', '2 findings'], ['Berechtigung', '1 finding']]);
|
||||
const practice = tile()!.querySelector<HTMLButtonElement>('[data-practice="ARCH"] button')!;
|
||||
expect(practice.getAttribute('aria-label')).toBe('Open BSI practice ARCH findings in Work (2)');
|
||||
await act(async () => practice.click());
|
||||
expect(onOpenBsiPractice).toHaveBeenCalledWith('ARCH');
|
||||
});
|
||||
|
||||
it('lists every derived requirement by practice in the Results window and opens Work from a requirement or practice', async () => {
|
||||
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi });
|
||||
await render();
|
||||
await openResults();
|
||||
// Catalog practice order, only practices with requirements; rows ranked by finding count with their ISO route.
|
||||
expect(groups().map(group => group.dataset.practice)).toEqual(['BER', 'ARCH', 'KONF']);
|
||||
expect(groups()[2].querySelector('.iso-controls__theme')?.textContent).toBe('KONF · Konfiguration2 requirements · 3 findings');
|
||||
expect([...groups()[2].querySelectorAll('.iso-controls__row')].map(row => [...row.children].map(part => part.textContent))).toEqual([
|
||||
['KONF.1.1', 'Synthetische Konfiguration · via A.8.20, A.8.9', '3'], ['KONF.2.1', 'Zweite Konfiguration · via A.8.20', '2']]);
|
||||
const source = document.querySelector('.bsi-requirements__source')?.textContent ?? '';
|
||||
expect(source).toContain('0.1-synthetic');
|
||||
expect(source).toContain('Not in the catalog: KONF.9.9.');
|
||||
expect(source).toContain('Synthetic attribution (CC BY-SA 4.0).');
|
||||
await act(async () => groups()[2].querySelector<HTMLButtonElement>('.iso-controls__row')!.click());
|
||||
expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1');
|
||||
expect(document.querySelector('.bsi-requirements')).toBeNull();
|
||||
await openResults();
|
||||
await act(async () => groups()[0].querySelector<HTMLButtonElement>('button.iso-controls__theme')!.click());
|
||||
expect(onOpenBsiPractice).toHaveBeenCalledWith('BER');
|
||||
expect(document.querySelector('.bsi-requirements')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows zero totals and an empty Results window when no requirement is derived', async () => {
|
||||
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
|
||||
await render();
|
||||
expect(facts()).toEqual([['Requirements', '0'], ['Findings', '0']]);
|
||||
await openResults();
|
||||
expect(document.querySelector('.iso-controls__empty')?.textContent).toBe('No BSI requirements derived from accepted ISO controls in this timeslot.');
|
||||
});
|
||||
|
||||
it('keeps pending values as an em dash and renders nothing while the Compliance Engine is off', async () => {
|
||||
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
|
||||
await render();
|
||||
expect(facts()).toEqual([['Requirements', '—'], ['Findings', '—']]);
|
||||
await act(async () => { root.unmount(); });
|
||||
root = createRoot(host);
|
||||
setEngineEnabled('compliance', false);
|
||||
await render();
|
||||
expect(tile()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ describe('Compliance Engine requests in the activity log', () => {
|
|||
['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading ISO mapping batch status'],
|
||||
['/projects/p/regulatory-mapping-batches', 'POST', 'Starting ISO mapping batch'],
|
||||
['/reference-data/iso-corpus', 'GET', 'Loading ISO corpus status'],
|
||||
['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus']
|
||||
['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus'],
|
||||
['/reference-data/bsi-grundschutz', 'GET', 'Loading BSI catalog'],
|
||||
['/reference-data/bsi-grundschutz/refresh', 'POST', 'Updating BSI catalog'],
|
||||
['/reference-data/bsi-grundschutz/reset', 'POST', 'Restoring bundled BSI catalog']
|
||||
])('labels %s %s', (path, method, label) => {
|
||||
expect(requestActivity(path, method)).toEqual({ area: 'Compliance Engine', label });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ vi.mock('../../src/app/AppProvider', () => {
|
|||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function scannerRecord() {
|
||||
function scannerRecord(occurrence = 'dummy-occurrence') {
|
||||
const record = makeSheet('Dummy', [['Title', 'Status', 'Source: Scan'], ['Dummy SMB signing check', 'Fail', 'Purple Knight']],
|
||||
'', 'dummy.csv', { isProjectSheet: true }).analysis.records[0];
|
||||
return { ...record, findingOccurrenceId: 'dummy-occurrence' };
|
||||
return { ...record, findingOccurrenceId: occurrence };
|
||||
}
|
||||
|
||||
const mappingResponse: RegulatoryMappingResponse = {
|
||||
|
|
@ -37,9 +37,9 @@ const mappingResponse: RegulatoryMappingResponse = {
|
|||
generationEnabled: true, state: 'empty', decisions: []
|
||||
};
|
||||
|
||||
async function renderReader() {
|
||||
async function renderReader(occurrence?: string) {
|
||||
await act(async () => {
|
||||
root.render(<FindingReader record={scannerRecord()} title="Dummy SMB signing check" linkedSheets={[]} resultReferences={[]}
|
||||
root.render(<FindingReader record={scannerRecord(occurrence)} title="Dummy SMB signing check" linkedSheets={[]} resultReferences={[]}
|
||||
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}
|
||||
projectId="dummy-project" backendStatus="connected"/>);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
|
@ -122,6 +122,48 @@ describe('Compliance Engine switch', () => {
|
|||
expect(isoButton()?.textContent).toBe('Map ISO controls (2)');
|
||||
});
|
||||
|
||||
it('lists the derived BSI requirements collapsed after the ISO section, grouped by ISO control, and none while off', async () => {
|
||||
const accepted = (controlId: string) => ({ version: 1 as const, id: `d-${controlId}`, ruleKey: 'purple-knight:dummy', sourceTool: 'purple-knight',
|
||||
mappingId: 'dummy-mapping', controlId, decision: 'accepted' as const, createdAt: '2026-10-05T10:00:00Z' });
|
||||
const requirement = (requirementId: string, title: string, isoControlIds: string[]) => ({ requirementId, title, practice: requirementId.split('.')[0],
|
||||
statement: `Synthetische Anforderung ${requirementId}.`, via: isoControlIds.map(isoControlId => ({ isoControlId, relationship: 'subset-of' as const })) });
|
||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue({ ...mappingResponse, state: 'succeeded',
|
||||
run: { candidates: [{ controlId: '8.2', annexAControlId: 'A.8.2', score: 0.9, evidence: [] },
|
||||
{ controlId: '5.18', annexAControlId: 'A.5.18', score: 0.8, evidence: [] }] } as never,
|
||||
decisions: [accepted('8.2'), accepted('5.18')],
|
||||
// Synthetic derived requirements; the real ones come from the active BSI mapping on the server.
|
||||
bsi: { catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution (CC BY-SA 4.0).',
|
||||
practices: [{ id: 'BER', title: 'Berechtigung' }], missingTargets: [],
|
||||
requirements: [requirement('BER.3.5', 'Synthetisches Konto', ['8.2']), requirement('BER.5.1', 'Synthetische Ausgabe', ['5.18', '8.2'])] } });
|
||||
await renderReader();
|
||||
const section = () => host.querySelector<HTMLDetailsElement>('details[data-bsi-requirements]');
|
||||
expect(host.querySelector('[data-iso-controls]')!.nextElementSibling).toBe(section());
|
||||
// Collapsed like Context, with the count in its summary.
|
||||
expect(section()!.open).toBe(false);
|
||||
expect(section()!.querySelector(':scope > summary')?.textContent).toBe('BSI requirements2 requirements derived from the accepted ISO controls');
|
||||
await act(async () => section()!.querySelector<HTMLElement>(':scope > summary')!.click());
|
||||
expect(section()!.open).toBe(true);
|
||||
// One group per ISO control in numeric order; a requirement reached twice is listed under each of its controls.
|
||||
const groups = [...section()!.querySelectorAll<HTMLElement>('.finding-bsi-requirements__group')];
|
||||
expect(groups.map(group => group.querySelector('.finding-bsi-requirements__route')?.textContent)).toEqual(['A.5.18 · subset-of', 'A.8.2 · subset-of']);
|
||||
expect(groups.map(group => [...group.querySelectorAll('li > details > summary')].map(row => row.textContent)))
|
||||
.toEqual([['BER.5.1Synthetische Ausgabe'], ['BER.3.5Synthetisches Konto', 'BER.5.1Synthetische Ausgabe']]);
|
||||
// A statement opens only with its requirement.
|
||||
const item = groups[1].querySelector<HTMLDetailsElement>('li[data-requirement="BER.3.5"] > details')!;
|
||||
expect(item.open).toBe(false);
|
||||
await act(async () => item.querySelector('summary')!.click());
|
||||
expect(item.open).toBe(true);
|
||||
expect(item.querySelector('.finding-bsi-requirements__statement')?.textContent).toBe('Synthetische Anforderung BER.3.5.');
|
||||
expect(section()!.querySelector('.finding-bsi-requirements__source')?.textContent?.trim())
|
||||
.toBe('Via BSI mapping 0.1-synthetic · catalog 2026-01-01 · Source: BSI Stand-der-Technik-Bibliothek, CC BY-SA 4.0');
|
||||
// Another finding starts collapsed again.
|
||||
await renderReader('dummy-other-occurrence');
|
||||
expect(section()?.open).toBe(false);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
await act(async () => setEngineEnabled('compliance', false));
|
||||
expect(section()).toBeNull();
|
||||
});
|
||||
|
||||
it('requests no Result DB batch status while off and hides the batch action', async () => {
|
||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue({
|
||||
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} },
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { AppProvider, useApp } from '../../src/app/AppProvider';
|
|||
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||||
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
|
||||
import {
|
||||
BSI_PRACTICE_FACET_ID,
|
||||
BSI_REQUIREMENT_FACET_ID,
|
||||
complianceWorkFacetIndex,
|
||||
ISO_CONTROL_FACET_ID,
|
||||
ISO_THEME_FACET_ID,
|
||||
|
|
@ -87,6 +89,46 @@ describe('Work ISO control and ISO theme facets', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Work `BSI requirement` and `BSI practice` (Phase 13F, stage 1): requirements derived from the accepted ISO
|
||||
// controls through the BSI mapping, after the ISO facets and before the final `Source: Scan`. Synthetic ids.
|
||||
describe('Work BSI requirement and BSI practice facets', () => {
|
||||
const withBsi: RegulatoryControlOverviewResponse = { ...assignments, bsi: {
|
||||
catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution.',
|
||||
practices: [{ id: 'BER', title: 'Berechtigung' }, { id: 'ARCH', title: 'Architektur' }, { id: 'DET', title: 'Detektion' }],
|
||||
requirements: [
|
||||
{ requirementId: 'ARCH.1.1', title: 'Synthetische Architektur', practice: 'ARCH', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] },
|
||||
{ requirementId: 'BER.1.1', title: 'Synthetische Berechtigung', practice: 'BER', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'equivalent-to' }] }
|
||||
],
|
||||
missingTargets: []
|
||||
} };
|
||||
const bsiOptions = (statusFilter = 'all', facetFilters = {}) => ({ ...options(statusFilter, facetFilters), complianceFacets: complianceWorkFacetIndex(withBsi) });
|
||||
|
||||
it('offers derived requirements and practices after the ISO facets, before the final Source: Scan control', () => {
|
||||
const facets = selectWorkFacets(workSheet(), bsiOptions());
|
||||
expect(facets.map(facet => facet.id).slice(-5)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, BSI_REQUIREMENT_FACET_ID, BSI_PRACTICE_FACET_ID, 'provenance:source-scan']);
|
||||
const requirement = facets.find(facet => facet.id === BSI_REQUIREMENT_FACET_ID)!;
|
||||
expect(requirement.label).toBe('BSI requirement');
|
||||
expect(requirement.values.map(value => [value.value, value.label, value.count])).toEqual([
|
||||
['arch.1.1', 'ARCH.1.1 · Synthetische Architektur', 3], ['ber.1.1', 'BER.1.1 · Synthetische Berechtigung', 1]]);
|
||||
// Catalog order, only practices with a derived requirement.
|
||||
expect(facets.find(facet => facet.id === BSI_PRACTICE_FACET_ID)!.values.map(value => [value.value, value.label, value.count]))
|
||||
.toEqual([['ber', 'BER · Berechtigung', 1], ['arch', 'ARCH · Architektur', 3]]);
|
||||
});
|
||||
|
||||
it('filters Work records by requirement and practice through their ISO controls, with the other filters still applying', () => {
|
||||
const sheet = workSheet();
|
||||
const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...bsiOptions(status, filters), sortColumn: '', sortDirection: 'asc' })
|
||||
.map(record => record.title);
|
||||
expect(titles({ [BSI_REQUIREMENT_FACET_ID]: { values: ['ber.1.1'] } })).toEqual(['Dummy check B']);
|
||||
expect(titles({ [BSI_PRACTICE_FACET_ID]: { values: ['arch'] } })).toEqual(['Dummy check A', 'Dummy check B', 'Dummy GPO setting']);
|
||||
expect(titles({ [BSI_PRACTICE_FACET_ID]: { values: ['arch'] } }, 'deviation')).toEqual(['Dummy check A', 'Dummy GPO setting']);
|
||||
});
|
||||
|
||||
it('adds no BSI facet when no requirement is derived', () => {
|
||||
expect(selectWorkFacets(workSheet(), options()).some(facet => facet.id.startsWith('compliance:bsi-'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({ loadCanonicalProjectWorkspace: vi.fn() }));
|
||||
|
||||
let host: HTMLDivElement;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AUTO_ACCEPT_COMMENT, RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service';
|
||||
import { AUTO_ACCEPT_COMMENT, RegulatoryMappingService, type RegulatoryMappingServiceOptions } from '../../server/compliance/regulatory-mapping-service';
|
||||
import { bundledBsiCatalogs } from '../../server/compliance/bsi-snapshots';
|
||||
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
|
||||
import { acceptedControlIds, REGULATORY_GPO_QUERY_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, type RegulatoryMappingBatchRun, type RegulatoryMappingDecision, type RegulatoryMappingRun } from '../../src/compliance/regulatory-mapping';
|
||||
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
|
||||
|
|
@ -29,15 +30,15 @@ function finding(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResul
|
|||
};
|
||||
}
|
||||
|
||||
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002'): RegulatoryChunk {
|
||||
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002', controlId = '5.1'): RegulatoryChunk {
|
||||
const reference = sourceStandard === 'ISO/IEC 27001';
|
||||
return {
|
||||
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? '27001-5.1' : '27002-5.1-purpose',
|
||||
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId: '5.1', annexAControlId: 'A.5.1',
|
||||
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? `27001-${controlId}` : `27002-${controlId}-purpose`,
|
||||
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId, annexAControlId: `A.${controlId}`,
|
||||
controlTheme: 'organizational', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
|
||||
sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
|
||||
retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test', sourceSha256: reference ? '1'.repeat(64) : '2'.repeat(64),
|
||||
chunkId: reference ? 'reference-5.1' : 'semantic-5.1', chunkIndex: 0, language: 'de', title: 'Synthetic control',
|
||||
chunkId: reference ? `reference-${controlId}` : `semantic-${controlId}`, chunkIndex: 0, language: 'de', title: 'Synthetic control',
|
||||
text: reference ? 'Synthetic normative statement.' : 'Synthetic semantic evidence.', textSha256: 'a'.repeat(64)
|
||||
};
|
||||
}
|
||||
|
|
@ -108,12 +109,14 @@ function mockRepository(findings: UnifiedResultProjection[]) {
|
|||
} as unknown as EngineRepository;
|
||||
}
|
||||
|
||||
function service(repository: EngineRepository, complete: (system: string, input: string) => Promise<unknown>) {
|
||||
function service(repository: EngineRepository, complete: (system: string, input: string) => Promise<unknown>, controlId = '5.1',
|
||||
extra: Partial<RegulatoryMappingServiceOptions> = {}) {
|
||||
return new RegulatoryMappingService(repository, repository, {
|
||||
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002') }]) }),
|
||||
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) },
|
||||
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002', controlId) }]) }),
|
||||
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001', controlId)]) },
|
||||
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3',
|
||||
now: () => new Date(stamp)
|
||||
now: () => new Date(stamp),
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -217,6 +220,47 @@ describe('regulatory mapping service', () => {
|
|||
await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project');
|
||||
});
|
||||
|
||||
it('derives BSI Kernel requirements from accepted ISO controls in the finding, overview and Work responses', async () => {
|
||||
const repository = mockRepository([finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' })]);
|
||||
const svc = service(repository, vi.fn(async () => ({ value: {
|
||||
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '8.9', relationship: 'primary', confidence: 'high',
|
||||
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-8.9', 'reference-8.9'] }]
|
||||
} })), '8.9');
|
||||
// Nothing accepted yet: no BSI block, so ISO-only responses stay unchanged.
|
||||
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi).toBeUndefined();
|
||||
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
||||
await svc.close();
|
||||
// The pinned BSI mapping assigns ISO 8.9 to five Kernel requirements of the practice KONF.
|
||||
const expected = ['KONF.2.1', 'KONF.2.1.1', 'KONF.2.5', 'KONF.10.1', 'KONF.10.5'];
|
||||
const single = await svc.get('project-1', 'scanner-finding:occurrence-1');
|
||||
expect(single.bsi?.requirements.map(item => item.requirementId)).toEqual(expected);
|
||||
expect(single.bsi?.requirements[0]).toMatchObject({ practice: 'KONF', via: [{ isoControlId: '8.9', relationship: 'subset-of' }] });
|
||||
expect(single.bsi?.requirements.every(item => item.statement && !item.statement.includes('{{'))).toBe(true);
|
||||
expect(single.bsi).toMatchObject({ crosswalkVersion: '1.0.1-qa', missingTargets: [] });
|
||||
expect(single.bsi?.license).toContain('CC BY-SA 4.0');
|
||||
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||
for (const response of [await svc.controlOverview('project-1', scope), await svc.controlAssignments('project-1', scope)]) {
|
||||
expect(response.bsi?.requirements.map(item => item.requirementId)).toEqual(expected);
|
||||
}
|
||||
// Rejecting the only ISO control removes every derived requirement.
|
||||
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '8.9', decision: 'rejected', expectedLatestDecisionId: single.decisions[0].id });
|
||||
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi).toBeUndefined();
|
||||
expect((await svc.controlOverview('project-1', scope)).bsi).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives BSI requirements from the active catalog after Update from BSI, not only from the pinned one', async () => {
|
||||
const active = structuredClone(bundledBsiCatalogs);
|
||||
// Synthetic newer state: the BSI mapping now also assigns ISO 8.9 to KONF.1.1.
|
||||
active.crosswalk.entries = active.crosswalk.entries.map(entry => entry.isoControlId === '8.9' ? { ...entry, targets: ['KONF.1.1'] } : entry);
|
||||
const svc = service(mockRepository([finding()]), vi.fn(async () => ({ value: {
|
||||
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '8.9', relationship: 'primary', confidence: 'high',
|
||||
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-8.9', 'reference-8.9'] }]
|
||||
} })), '8.9', { bsiCatalogs: async () => active });
|
||||
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
||||
await svc.close();
|
||||
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi?.requirements.map(item => item.requirementId)).toEqual(['KONF.1.1']);
|
||||
});
|
||||
|
||||
it('reports every batch and single finding mapping globally, marked by where it came from, on the Client Info overview only', async () => {
|
||||
const repository = mockRepository([finding(),
|
||||
finding({ assessmentId: 'assessment-3', scanRunId: 'run-3', resultProjectionId: 'scanner-finding:earlier', sourceResultId: 'earlier',
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe('local ASSESSOR HTTP server', () => {
|
|||
|
||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||
expect(await health.json()).toEqual({
|
||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.21.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.22.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||
});
|
||||
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue