Compare commits

...

1 Commits
main ... binh

21 changed files with 1383 additions and 2 deletions

View File

@ -10,6 +10,22 @@ ASSESSOR_GPO_JOB_LEASE_MS=60000
ASSESSOR_GPO_JOB_RETRY_MS=5000
ASSESSOR_GPO_JOB_POLL_MS=1000
# Optional regulation mapping (retrieval slice). Disabled unless the embedding
# endpoint below is set. Qdrant is an internal service; run `npm run qdrant:up`
# locally. Ingest the catalog with `npm run regmap:ingest <catalog.jsonl>`.
# The catalog JSONL holds licensed text and must stay OUTSIDE the repository.
# ASSESSOR_QDRANT_URL=http://127.0.0.1:6333
# ASSESSOR_QDRANT_API_KEY=
# ASSESSOR_QDRANT_COLLECTION=regulation_catalog_v1
# ASSESSOR_EMBEDDING_BASE_URL=http://127.0.0.1:8080/v1
# ASSESSOR_EMBEDDING_MODEL=your-embedding-model
# ASSESSOR_EMBEDDING_API_KEY=
# ASSESSOR_EMBEDDING_DIMENSIONS=1024
# ASSESSOR_EMBEDDING_QUERY_PREFIX=
# ASSESSOR_EMBEDDING_PASSAGE_PREFIX=
# ASSESSOR_REGMAP_CATALOG_VERSION=DIN EN ISO/IEC 27002:2024-01
# ASSESSOR_REGMAP_CATALOG_FILE=
# Optional AI Overview. Leave ASSESSOR_AI_MODE unset to keep outbound AI disabled.
# ASSESSOR_AI_MODE=local
# ASSESSOR_AI_BASE_URL=http://127.0.0.1:11434/v1/

5
.gitignore vendored
View File

@ -10,9 +10,14 @@ src-tauri/gen/
vite.config.js
vite.config.d.ts
*.png
Mapping-test/
.env.postgres
.assessor-data/
# Licensed regulation-catalog files must never be committed.
*.catalog.jsonl
regulation-catalog*.jsonl
.claude/worktrees/
.claude/scheduled_tasks.lock
.codex-patches/

View File

@ -104,6 +104,32 @@ Das kanonische Modell kann eine Quelle ausdrücklich als eingebettet, extern ode
nicht aufbewahrt kennzeichnen. Backupdateien benötigen gesonderten Zugriffsschutz
und ein in Phase 14 festzulegendes Aufbewahrungs- und Löschkonzept.
## Regulierungssuche-Grenze (Qdrant und Embedding-Endpunkt)
Die Regulierungssuche (Retrieval-Stufe) ordnet Finding-Definitionen den passenden
Regelwerkseinheiten zu. Sie ist standardmäßig deaktiviert und wird erst durch
`ASSESSOR_EMBEDDING_BASE_URL` aktiviert.
- Alle Aufrufe erfolgen ausschließlich backendseitig an die ausdrücklich
konfigurierten internen Endpunkte (Qdrant und ein OpenAI-kompatibler
Embedding-Dienst). Die React-App ruft diese Dienste nie auf; die CSP bleibt bei
`connect-src 'self'`.
- Qdrant ist ein internes Backend, das in der Deployment-Compose nicht auf einer
routbaren Schnittstelle veröffentlicht wird; die Entwicklungs-Compose bindet nur
an `127.0.0.1`.
- Es werden nur definitionsbezogene Felder (Titel, Beschreibung, Empfehlung,
Kategorie) eingebettet – niemals Vorkommnisdaten, Assets, Originalwerte,
Mandanten- oder Projektmetadaten.
- Anfragetexte, Katalogtexte, Prompts und Vektorwerte werden nicht protokolliert.
Protokoll und Antworten enthalten nur IDs, Zähler, Versionen, Dauer und stabile
Fehlercodes. Ein Fähigkeitsindikator (`disabled`, `misconfigured`, `ready`) wird
über `/api/v1/health` und `/api/v1/regulation-mapping/capability` bereitgestellt.
- Prompt-, Kandidaten- und Antwortgrößen sowie Zeitlimits sind begrenzt.
- Der Regulierungskatalog enthält lizenzierten Volltext. Die Katalogdatei bleibt
außerhalb des Repositorys (git-ignoriert); nur das Einspielskript wird
versioniert. Der lizenzierte Text lebt im internen Vektorspeicher und wird
transient zur Laufzeit genutzt.
## Tauri-Grenzen
- Die Desktop-Capability enthält ausschließlich `core:default`.

View File

@ -22,6 +22,15 @@ services:
ASSESSOR_POSTGRES_USER: ${ASSESSOR_POSTGRES_USER:?set ASSESSOR_POSTGRES_USER}
ASSESSOR_POSTGRES_PASSWORD: ${ASSESSOR_POSTGRES_PASSWORD:?set ASSESSOR_POSTGRES_PASSWORD}
ASSESSOR_DATABASE_SSL: disable
# Regulation mapping (disabled unless the embedding endpoint below is set).
# Qdrant is an internal service, never published to the host.
ASSESSOR_QDRANT_URL: http://qdrant:6333
ASSESSOR_QDRANT_COLLECTION: ${ASSESSOR_QDRANT_COLLECTION:-regulation_catalog_v1}
ASSESSOR_EMBEDDING_BASE_URL: ${ASSESSOR_EMBEDDING_BASE_URL:-}
ASSESSOR_EMBEDDING_MODEL: ${ASSESSOR_EMBEDDING_MODEL:-}
ASSESSOR_EMBEDDING_API_KEY: ${ASSESSOR_EMBEDDING_API_KEY:-}
ASSESSOR_EMBEDDING_DIMENSIONS: ${ASSESSOR_EMBEDDING_DIMENSIONS:-}
ASSESSOR_REGMAP_CATALOG_VERSION: ${ASSESSOR_REGMAP_CATALOG_VERSION:-}
ports:
- "${ASSESSOR_BIND:?set ASSESSOR_BIND}:${ASSESSOR_PUBLISH_PORT:?set ASSESSOR_PUBLISH_PORT}:4173"
volumes:
@ -29,6 +38,8 @@ services:
depends_on:
postgres:
condition: service_healthy
qdrant:
condition: service_started
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4173/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
@ -51,6 +62,17 @@ services:
volumes:
- assessor-postgres-data:/var/lib/postgresql/data
qdrant:
image: qdrant/qdrant:v1.12.4
restart: unless-stopped
# Internal service only — intentionally not published to the host.
environment:
# No outbound telemetry (project no-telemetry policy).
QDRANT__TELEMETRY_DISABLED: "true"
volumes:
- assessor-qdrant-data:/qdrant/storage
volumes:
assessor-artifacts:
assessor-postgres-data:
assessor-qdrant-data:

19
docker-compose.qdrant.yml Normal file
View File

@ -0,0 +1,19 @@
services:
qdrant:
image: qdrant/qdrant:v1.12.4
restart: unless-stopped
environment:
# No outbound telemetry (project no-telemetry policy).
QDRANT__TELEMETRY_DISABLED: "true"
ports:
# Published on the distro interface so WSL2's localhost forwarding relays
# the Windows host's 127.0.0.1:6333 to the container (a 127.0.0.1-bound
# publish is not forwarded out of the WSL distro). In WSL2 NAT mode this
# stays isolated from the physical LAN; the backend and ingestion still
# reach it at 127.0.0.1. Only for local development.
- "${ASSESSOR_QDRANT_PORT:-6333}:6333"
volumes:
- assessor-qdrant-data:/qdrant/storage
volumes:
assessor-qdrant-data:

View File

@ -94,6 +94,7 @@ Die heutige Tauri-Anwendung bleibt zunächst eine lokale Laufzeitumgebung. Das
| 13A | Ingestion 2.0: Formatadapter, Source Extractors und Mappingprofile konsolidieren | abgeschlossen |
| 13B | Deterministische GPO-Backup- und Microsoft-Baseline-Analyse | in Arbeit |
| 13C | Scanner Context und Environment Inventory | in Arbeit |
| 13D | Regulierungssuche (Retrieval-Stufe): Qdrant und Embedding-Endpunkt | in Arbeit |
| 14 | Mehrbenutzer- und Firmenbetrieb | offen |
## 7. Phasen im Detail
@ -1328,6 +1329,49 @@ Abnahmekriterien:
- Betrieb, Wiederherstellung und Update sind dokumentiert und getestet.
- Es findet keine Übertragung an externe Dienste statt, sofern dies nicht später ausdrücklich beschlossen wird.
### Phase 13D – Regulierungssuche (Retrieval-Stufe)
Status: in Arbeit. Umgesetzt ist die erste Scheibe „Ingestion + Retrieval“ des
in `Mapping-test/REGULATION_MAPPING_PLAN.md` skizzierten Regulierungs-Mappings.
Ausdrücklich noch nicht enthalten sind LLM-Auswahl, Persistenz von Mapping-Läufen
und Review-Entscheidungen, ResultDB-Projektion/Facetten sowie UI.
Ziel und Umfang dieser Scheibe:
- Eine Finding-Definition wird definitionsbezogen zu den passenden atomaren
Regelwerkseinheiten (z. B. ISO-27002-Controls) zugeordnet: Query einbetten,
Kosinussuche im Vektorspeicher, Gruppierung der Treffer je `unit_id`, Rückgabe
der besten fünf Einheiten mit Score. Keine Auswahl, keine Persistenz.
- Vektorspeicher ist **Qdrant** in Docker (nicht pgvector). Embeddings kommen von
einem konfigurierbaren, OpenAI-kompatiblen lokalen Endpunkt (llama.cpp/Ollama/
LM Studio/LiteLLM).
- Die Funktion ist standardmäßig deaktiviert und wird nur durch
`ASSESSOR_EMBEDDING_BASE_URL` aktiviert. Ein Fähigkeitsindikator unterscheidet
`disabled`, `misconfigured` und `ready` über `/api/v1/health` und
`/api/v1/regulation-mapping/capability`.
- Der Katalog wird einmalig über `npm run regmap:ingest <catalog.jsonl>` in eine
Qdrant-Collection eingespielt (Vektorgröße = Embedding-Dimensionen, Cosine).
Die Katalogdatei enthält lizenzierten Volltext und bleibt außerhalb des
Repositorys.
- Endpunkt `POST /api/v1/regulation-mapping/retrieve` nimmt eine synthetiktaugliche
Definitions-Nutzlast entgegen und liefert Kandidaten; nicht verfügbare Fähigkeit
ergibt `503 regulation_mapping_unavailable` statt eines Absturzes.
Sicherheit: Aufrufe nur backendseitig an die konfigurierten internen Endpunkte;
keine React-Zugriffe; nur definitionsbezogene Felder werden eingebettet; Anfrage-,
Katalog- und Vektorinhalte werden nicht protokolliert. Siehe `SECURITY.md`,
Abschnitt „Regulierungssuche-Grenze“.
Verifikation: `npm test`, `npm run build`, Local-only- und Designprüfungen grün.
Retrieval-Qualität gegen die Prototyp-Ergebnisse ist gegen einen laufenden
Qdrant- und Embedding-Endpunkt zu prüfen, bevor die nächste Scheibe (LLM-Auswahl,
Persistenz, Review) begonnen wird.
Abweichung zum Ausgangsplan: `REGULATION_MAPPING_PLAN.md` nannte pgvector und
LiteLLM innerhalb von PostgreSQL. Auf Nutzerwunsch wird Qdrant als eigenständiger,
leicht hostbarer Vektor-Docker verwendet; die Embedding-Anbindung bleibt
OpenAI-kompatibel.
## 8. Aktuell empfohlene Umsetzungsetappe
Die naechste Umsetzung fuehrt Phase 13B auf dem konsolidierten Import-, Policy-Analyse- und Orchestrierungsunterbau fort:
@ -1795,6 +1839,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll
| Datum | Änderung |
| 2026-09-02 | Phase 13D begonnen: erste Scheibe „Ingestion + Retrieval“ der Regulierungssuche umgesetzt. Neuer Vektorspeicher ist Qdrant in Docker (Ersatz für den zuvor skizzierten pgvector-Ansatz), Embeddings über einen konfigurierbaren OpenAI-kompatiblen lokalen Endpunkt. Backend-Module unter `server/regmap/` (Konfiguration/Fähigkeit, Embedding- und Qdrant-Client per nativem `fetch` ohne neue Abhängigkeit, Retrieval-Service mit Gruppierung je `unit_id`), einmaliges Einspielskript `server/regmap/ingest-catalog.mjs` (`npm run regmap:ingest`), Endpunkte `GET /api/v1/regulation-mapping/capability` und `POST /api/v1/regulation-mapping/retrieve` sowie additive Fähigkeitsanzeige auf `/api/v1/health`. Funktion standardmäßig deaktiviert, aktiviert nur über `ASSESSOR_EMBEDDING_BASE_URL`; Zustände `disabled`/`misconfigured`/`ready`. Qdrant als internes Backend in `docker-compose.deploy.yml` (nicht veröffentlicht) und lokal in `docker-compose.qdrant.yml` (`127.0.0.1`). Lizenzierte Katalogdatei bleibt git-ignoriert. Keine LLM-Auswahl, keine Persistenz/Review in dieser Scheibe. `SECURITY.md` um die Regulierungssuche-Grenze ergänzt. Gesamtlauf 763 bestanden, 8 optionale PostgreSQL-Tests übersprungen; Local-only- und Designprüfung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Schema- oder Datenmigration; Retrieval-Qualität gegen laufenden Qdrant-/Embedding-Endpunkt noch zu verifizieren. |
| 2026-08-24 | Phase-14-Containerverbindung korrigiert: Die Repository-Laufzeit uebernimmt `ASSESSOR_POSTGRES_HOST` in die PostgreSQL-Verbindungszeichenfolge, sodass der App-Container den Compose-Dienst `postgres` statt seines eigenen Loopbacks anspricht. `ASSESSOR_DATABASE_URL` behaelt Vorrang und `127.0.0.1` bleibt der lokale Standard. Docker-Hostname und lokaler Fallback sind synthetisch regressionsgeprueft; Gesamtlauf 735 bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Schema- oder Datenmigration; Phase 14 bleibt offen. |
| 2026-08-24 | Phase-7-Segment-/Kachelgrammatik nach Nutzerreferenz anwendungsweit umgesetzt und als Projektvorgabe verankert: gemeinsame Segment- und Kacheltokens fuer Flaeche, Kontur, Radius, Inset, Gap, Schatten, Auswahl und Breiten; flache Gruppencontainer; gleichartige Inhalts-, Aktions- und Hinzufuegekacheln; Statusfarben nur als Akzent; unvollstaendige Zeilen bleiben unstretched. Datahub, Dashboard, Sidebar, Work-TileGrid, DB Structure, Policy Analysis und statische Pathfinder-Karten verwenden den Vertrag. Die verbindliche Nutzerregel verbietet abgeschnittene, geklemmte, ellipsierte oder verborgene Titel, Werte, Labels, Zusatztexte und Aktionen. Dashboardstatuskarten wurden auf 290 beziehungsweise 210 px abgestimmt; Anzahl und Prozentwert stehen getrennt, sodass `Compliance` und Werte wie `453` vollstaendig bleiben. Das Donut-Raster nutzt auf Desktop sechs Spalten und die Matrix verteilt ihre feste Spaltenzahl ueber die volle Segmentbreite. Bei Platzmangel wird Inhalt neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv umgebrochen. Sechzehn fokussierte Layout-/Donuttests und der Gesamtlauf mit 736 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung mit 0 Rohfarben sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-7-Finding-Titelhierarchie nach visueller Nutzerkorrektur nachgeschaerft: Trefferlistentitel bleiben 16 px, verwenden aber Medium 500 statt Semibold; der geoeffnete Finding-Titel verwendet eine neue zentrale 20-px-Detailtitelrolle mit Semibold. Finding-Abschnitt 18 px, Matrixkacheln 16 px und die 16-px-Untergrenze fuer Inhalt bleiben unveraendert. Titel brechen nur an Wortgrenzen. Drei fokussierte Typografieregressionen sowie der Gesamtlauf mit 736 bestandenen und 8 uebersprungenen Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |

107
docs/REGULATION_MAPPING.md Normal file
View File

@ -0,0 +1,107 @@
# Regulation mapping — quick command reference
The regulation retrieval feature maps a finding definition to the best-matching
regulation units (e.g. ISO 27002 controls) using a Qdrant vector database and an
OpenAI-compatible embedding endpoint. It is **disabled unless configured** and is
backend-only.
> This is the "ingestion + retrieval" slice (Phase 13D). LLM selection, review
> workflow and UI are not part of it yet. See `docs/IMPLEMENTATION_PLAN.md`
> (Phase 13D) and `SECURITY.md` (Regulierungssuche-Grenze).
## Prerequisites
- Docker (Qdrant runs in a container).
- An OpenAI-compatible embedding endpoint serving **bge-m3** (multilingual,
1024 dimensions, no prefixes). Either:
- **llama.cpp**: download `bge-m3-FP16.gguf` (or `bge-m3-Q8_0.gguf`) from
`gpustack/bge-m3-GGUF`, then
`llama-server -m bge-m3-FP16.gguf --embedding --pooling cls -c 8192 -ub 8192 --port 8080`
→ base URL `http://127.0.0.1:8080/v1`.
- **Ollama**: `ollama pull bge-m3` → base URL `http://127.0.0.1:11434/v1`.
- The catalog file `iso27002-controls-embed.jsonl` — it contains **licensed**
text and must stay **outside** the repository (it lives under `Mapping-test/`,
which is git-ignored).
## One-time setup
```bash
# 1. Start Qdrant (reachable at http://127.0.0.1:6333)
npm run qdrant:up
# 2. Start the embedding model (separate terminal / service)
# llama.cpp:
llama-server -m bge-m3-FP16.gguf --embedding --pooling cls -c 8192 -ub 8192 --port 8080
# or Ollama: ollama pull bge-m3 && ollama serve (port 11434)
```
Add to `.env.postgres` (create it from `.env.postgres.example` if needed):
```ini
ASSESSOR_QDRANT_URL=http://127.0.0.1:6333
ASSESSOR_QDRANT_COLLECTION=regulation_catalog_v1
ASSESSOR_EMBEDDING_BASE_URL=http://127.0.0.1:8080/v1
# ^ llama.cpp on :8080; for Ollama use http://127.0.0.1:11434/v1
ASSESSOR_EMBEDDING_MODEL=bge-m3
ASSESSOR_EMBEDDING_DIMENSIONS=1024
ASSESSOR_REGMAP_CATALOG_VERSION=DIN EN ISO/IEC 27002:2024-01
# For multilingual-e5-* instead of bge-m3, the prefixes must end with a space:
# ASSESSOR_EMBEDDING_QUERY_PREFIX=query:
# ASSESSOR_EMBEDDING_PASSAGE_PREFIX=passage:
```
```bash
# 3. Embed the catalog into Qdrant (idempotent — safe to re-run)
npm run regmap:ingest "Mapping-test/test/iso27002-controls-embed.jsonl"
```
## Run the app
```bash
# Development (client on :5173, backend API on :4173, reads .env.postgres)
npm run dev
# Production-like (build, then serve everything on :4173)
npm run serve:local
```
## Verify
```bash
# Capability should report "ready" once Qdrant + embeddings + collection are set
curl http://127.0.0.1:4173/api/v1/regulation-mapping/capability
# Health also carries the capability
curl http://127.0.0.1:4173/api/v1/health
# Retrieve candidates for a finding (POST needs the same-origin mutation header)
curl -X POST http://127.0.0.1:4173/api/v1/regulation-mapping/retrieve -H "Content-Type: application/json" -H "X-Assessor-Request: 1" -d '{"definition":{"title":"RC4 encryption enabled on Domain Controller","description":"Weak cipher permitted on DC"}}'
```
Capability states: `disabled` (no embedding URL) · `misconfigured` (collection
missing, dimension/model mismatch, or endpoint unreachable) · `ready`.
## Command reference
| Command | What it does |
|---|---|
| `npm run qdrant:up` | Start the Qdrant container (detached). |
| `npm run qdrant:down` | Stop and remove the Qdrant container. |
| `npm run qdrant:logs` | Follow Qdrant logs. |
| `npm run regmap:ingest <file.jsonl>` | Embed the catalog and upsert into Qdrant. |
| `npm run dev` | Dev stack: client `:5173`, backend `:4173` (loads `.env.postgres`). |
| `npm run serve:local` | Build and serve the whole app on `:4173`. |
| `npm run start:local` | Serve an already-built app on `:4173`. |
| `npm run db:up` / `db:down` | Start / stop the local PostgreSQL container. |
| `npm test` / `npm run build` | Test suite / production build. |
## Notes
- Qdrant's data persists in the `assessor-qdrant-data` Docker volume across
`qdrant:down` / `qdrant:up`. To wipe it: `docker volume rm assessor_assessor-qdrant-data`.
- `ASSESSOR_EMBEDDING_DIMENSIONS` **must** match the embedding model's output
size, or the collection validation fails as `misconfigured`. Changing models
means re-ingesting (delete the collection or use a new `ASSESSOR_QDRANT_COLLECTION`).
- On this machine, the dev Qdrant compose publishes port `6333` on the WSL
distro interface so Windows `127.0.0.1:6333` reaches it (WSL2 does not forward a
`127.0.0.1`-bound container port). It stays isolated from the LAN in WSL2 NAT mode.

View File

@ -14,6 +14,10 @@
"db:up": "docker compose --env-file .env.postgres -f docker-compose.postgres.yml up -d",
"db:down": "docker compose --env-file .env.postgres -f docker-compose.postgres.yml down",
"db:logs": "docker compose --env-file .env.postgres -f docker-compose.postgres.yml logs -f postgres",
"qdrant:up": "docker compose -f docker-compose.qdrant.yml up -d",
"qdrant:down": "docker compose -f docker-compose.qdrant.yml down",
"qdrant:logs": "docker compose -f docker-compose.qdrant.yml logs -f qdrant",
"regmap:ingest": "node --env-file-if-exists=.env.postgres server/regmap/ingest-catalog.mjs",
"test:postgres": "vitest run --config vitest.config.ts tests/postgres/postgres-repository.integration.test.ts && vitest run --config vitest.config.ts tests/postgres/gpo-backup-management.integration.test.ts tests/postgres/policy-analysis.integration.test.ts",
"test": "vitest run --config vitest.config.ts && node tests/local-only-check.mjs && node tests/design-language-check.mjs",
"test:security": "node tests/local-only-check.mjs",

View File

@ -27,6 +27,11 @@ import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
import {
disabledRegulationMapping,
RegulationMappingUnavailableError,
type RegulationMapping
} from './regmap/regulation-mapping';
import { directImportExecutor, type ImportExecutor } from './import-executor';
import { createCanonicalImportBatch } from '../src/importers/canonical-import';
import { processingScopeIssues } from '../src/domain/processing-compatibility';
@ -57,6 +62,7 @@ import {
type ImportBatchPreviewResponse,
type ImportSourceParseRequest,
type ImportSourceValidationRequest,
type RegulationMappingRetrieveRequest,
type GpoBackupSubmissionRequest,
type GpoBackupSubmissionResponse,
type EnvironmentProfileCreateRequest,
@ -130,6 +136,7 @@ export interface AssessorHttpServerOptions {
gpoPolicyFindingService?: GpoPolicyFindingService;
gpoPolicyAnalysisOverviewService?: GpoPolicyAnalysisOverviewService;
microsoftSecurityBaselineService?: MicrosoftSecurityBaselineService;
regulationMapping?: RegulationMapping;
}
class HttpError extends Error {
@ -365,6 +372,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
const now = options.now ?? (() => new Date());
const importExecutor = options.importExecutor ?? directImportExecutor;
const previewTtlMs = options.previewTtlMs ?? DEFAULT_PREVIEW_TTL_MS;
const regulationMapping = options.regulationMapping ?? disabledRegulationMapping();
const importSessions = new Map<string, ImportSession>();
const importBatchPreviews = new Map<string, CachedImportBatchPreview>();
const auditEvents: AuditEvent[] = [];
@ -391,10 +399,32 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
status: 'ok', application: 'ASSESSOR', apiVersion: 1,
releaseId: options.releaseId,
developmentInstanceId: options.developmentInstanceId,
repositoryRevision: repository.revision, persistence: options.persistence ?? 'memory'
repositoryRevision: repository.revision, persistence: options.persistence ?? 'memory',
regulationMapping: regulationMapping.cachedCapability()
};
status = 200; sendJson(response, status, result, requestId); return;
}
if (method === 'GET' && segments.join('/') === 'regulation-mapping/capability') {
status = 200; sendJson(response, status, await regulationMapping.refreshCapability(), requestId); return;
}
if (method === 'POST' && segments.join('/') === 'regulation-mapping/retrieve') {
const body = await readJson<RegulationMappingRetrieveRequest>(request, maximumBodyBytes);
const definition = body?.definition;
if (!definition || typeof definition.title !== 'string' || !definition.title.trim()) {
throw new HttpError(400, 'invalid_regulation_definition', 'Ein Finding-Titel ist für die Regulierungssuche erforderlich.');
}
try {
const retrieval = await regulationMapping.retrieve(definition);
status = 200; sendJson(response, status, retrieval, requestId); return;
} catch (error) {
if (error instanceof RegulationMappingUnavailableError) {
status = 503;
sendJson(response, status, { error: { code: 'regulation_mapping_unavailable', message: 'Die Regulierungssuche ist derzeit nicht verfügbar.', requestId }, capability: error.report }, requestId);
return;
}
throw error;
}
}
if (method === 'GET' && segments.length === 1 && segments[0] === 'audit-events') {
status = 200; sendJson(response, status, auditEvents.slice().reverse(), requestId); return;
}

View File

@ -15,6 +15,7 @@ import { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service';
import { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service';
import { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
import { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
import { createRegulationMapping } from './regmap/regulation-mapping-factory';
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
if (value === undefined || value.trim() === '') return fallback;
@ -66,6 +67,9 @@ async function main(): Promise<void> {
const gpoPolicyEvaluationService = new GpoPolicyEvaluationService(runtime.repository);
const gpoPolicyFindingService = new GpoPolicyFindingService(runtime.repository);
const gpoPolicyAnalysisOverviewService = new GpoPolicyAnalysisOverviewService(runtime.repository);
const regulationMapping = createRegulationMapping();
// Best-effort startup validation so /api/v1/health reports a meaningful capability.
await regulationMapping.refreshCapability().catch(() => undefined);
await microsoftSecurityBaselineService.refreshDetectedMetadata();
const baselineBackfill = await microsoftSecurityBaselineService.reprocessPending();
if (baselineBackfill.failed) console.warn('Microsoft-Baseline-Backfill:', baselineBackfill.analyzed,
@ -101,6 +105,7 @@ async function main(): Promise<void> {
gpoPolicyEvaluationService,
gpoPolicyFindingService,
gpoPolicyAnalysisOverviewService,
regulationMapping,
importExecutor
});
server.listen(parsedPort, host, () => {

View File

@ -0,0 +1,95 @@
// OpenAI-compatible embedding client (backend-only). Targets the explicitly
// configured internal endpoint (llama.cpp / Ollama / LM Studio / LiteLLM).
// Vectors are L2-normalized so a plain dot product equals cosine similarity.
//
// Never logs input text, vectors, or endpoint payloads.
export interface EmbeddingClientConfig {
baseUrl: string;
model: string;
apiKey?: string;
dimensions: number;
queryPrefix?: string;
passagePrefix?: string;
requestTimeoutMs: number;
}
export type EmbeddingKind = 'query' | 'passage';
/** Safe, stable error codes — carry no sensitive content. */
export class EmbeddingError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = 'EmbeddingError';
}
}
export interface EmbeddingClient {
embed(texts: string[], kind: EmbeddingKind): Promise<number[][]>;
}
function normalize(vector: number[]): number[] {
let sumSquares = 0;
for (const value of vector) sumSquares += value * value;
const magnitude = Math.sqrt(sumSquares);
if (!Number.isFinite(magnitude) || magnitude === 0) return vector;
return vector.map(value => value / magnitude);
}
export function createEmbeddingClient(config: EmbeddingClientConfig): EmbeddingClient {
const prefixFor = (kind: EmbeddingKind): string =>
(kind === 'query' ? config.queryPrefix : config.passagePrefix) ?? '';
return {
async embed(texts: string[], kind: EmbeddingKind): Promise<number[][]> {
if (texts.length === 0) return [];
const prefix = prefixFor(kind);
const input = texts.map(text => `${prefix}${text}`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
let response: Response;
try {
response = await fetch(`${config.baseUrl}/embeddings`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {})
},
body: JSON.stringify({ model: config.model, input })
});
} catch (error) {
const aborted = error instanceof Error && error.name === 'AbortError';
throw new EmbeddingError(aborted ? 'embedding_timeout' : 'embedding_unreachable',
aborted ? 'Der Embedding-Dienst hat nicht rechtzeitig geantwortet.' : 'Der Embedding-Dienst ist nicht erreichbar.');
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new EmbeddingError('embedding_http_error', `Embedding-Dienst antwortete mit HTTP ${response.status}.`);
}
let payload: { data?: Array<{ embedding?: number[] }> };
try {
payload = await response.json() as typeof payload;
} catch {
throw new EmbeddingError('embedding_invalid_response', 'Die Antwort des Embedding-Dienstes war kein gültiges JSON.');
}
const rows = Array.isArray(payload.data) ? payload.data : [];
if (rows.length !== texts.length) {
throw new EmbeddingError('embedding_count_mismatch', 'Der Embedding-Dienst lieferte eine andere Anzahl an Vektoren als angefragt.');
}
return rows.map(row => {
const vector = Array.isArray(row.embedding) ? row.embedding : [];
if (vector.length !== config.dimensions) {
throw new EmbeddingError('dimension_mismatch',
`Der Embedding-Dienst lieferte ${vector.length} Dimensionen, erwartet wurden ${config.dimensions}.`);
}
return normalize(vector);
});
}
};
}

View File

@ -0,0 +1,174 @@
// One-time regulation-catalog ingestion into Qdrant.
//
// Reads a LOCAL, git-ignored catalog JSONL (one embedding chunk per line),
// embeds each chunk's `embed_text` via an OpenAI-compatible endpoint, and
// upserts the vectors + metadata into a Qdrant collection. Idempotent: re-runs
// upsert the same deterministic point ids.
//
// The catalog file contains licensed authoritative text and must stay OUTSIDE
// the repository. This script is committed; the catalog is not.
//
// Usage:
// node server/regmap/ingest-catalog.mjs [catalog.jsonl]
//
// Required env: ASSESSOR_QDRANT_URL, ASSESSOR_EMBEDDING_BASE_URL,
// ASSESSOR_EMBEDDING_MODEL, ASSESSOR_EMBEDDING_DIMENSIONS
// Optional env: ASSESSOR_QDRANT_API_KEY, ASSESSOR_QDRANT_COLLECTION,
// ASSESSOR_EMBEDDING_API_KEY, ASSESSOR_EMBEDDING_PASSAGE_PREFIX,
// ASSESSOR_REGMAP_CATALOG_VERSION, ASSESSOR_REGMAP_CATALOG_FILE,
// ASSESSOR_REGMAP_INGEST_BATCH (default 32)
//
// Prints counts only — never catalog text, vectors or payloads.
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const env = process.env;
function required(name) {
const value = env[name]?.trim();
if (!value) throw new Error(`${name} ist erforderlich.`);
return value;
}
function stripTrailingSlash(url) {
return url.replace(/\/+$/, '');
}
const catalogFile = (process.argv[2] ?? env.ASSESSOR_REGMAP_CATALOG_FILE ?? '').trim();
if (!catalogFile) throw new Error('Katalogdatei fehlt: als Argument oder über ASSESSOR_REGMAP_CATALOG_FILE angeben.');
const qdrantUrl = stripTrailingSlash(required('ASSESSOR_QDRANT_URL'));
const qdrantApiKey = env.ASSESSOR_QDRANT_API_KEY?.trim() || undefined;
const collection = env.ASSESSOR_QDRANT_COLLECTION?.trim() || 'regulation_catalog_v1';
const embeddingBaseUrl = stripTrailingSlash(required('ASSESSOR_EMBEDDING_BASE_URL'));
const embeddingModel = required('ASSESSOR_EMBEDDING_MODEL');
const embeddingApiKey = env.ASSESSOR_EMBEDDING_API_KEY?.trim() || undefined;
const dimensions = Number(required('ASSESSOR_EMBEDDING_DIMENSIONS'));
if (!Number.isInteger(dimensions) || dimensions < 8) throw new Error('ASSESSOR_EMBEDDING_DIMENSIONS muss eine positive Ganzzahl sein.');
const passagePrefix = env.ASSESSOR_EMBEDDING_PASSAGE_PREFIX ?? '';
const expectedCatalogVersion = env.ASSESSOR_REGMAP_CATALOG_VERSION?.trim() || undefined;
const batchSize = Math.max(1, Math.min(256, Number(env.ASSESSOR_REGMAP_INGEST_BATCH ?? 32) || 32));
// Deterministic UUID (v5-like) from a stable chunk id, so re-runs upsert in place.
function pointId(chunkId) {
const hex = createHash('sha256').update(chunkId).digest('hex').slice(0, 32).split('');
hex[12] = '5';
hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
const h = hex.join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Retry only transient connection-level failures (a thrown `fetch failed`),
// not HTTP status codes. Local Qdrant/embedding endpoints on WSL occasionally
// reset a connection mid-run; a fresh connection then succeeds.
async function fetchRetry(url, options, attempts = 12) {
for (let attempt = 1; ; attempt += 1) {
try {
return await fetch(url, options);
} catch (error) {
if (attempt >= attempts) throw error;
if (attempt === 1) process.stdout.write(`\n (endpoint hiccup, retrying up to ${attempts - 1}x)`);
await sleep(Math.min(3000, 750 * attempt));
}
}
}
async function embed(texts) {
const response = await fetch(`${embeddingBaseUrl}/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(embeddingApiKey ? { Authorization: `Bearer ${embeddingApiKey}` } : {}) },
body: JSON.stringify({ model: embeddingModel, input: texts.map(t => `${passagePrefix}${t}`) })
});
if (!response.ok) throw new Error(`Embedding-Dienst antwortete mit HTTP ${response.status}.`);
const payload = await response.json();
const rows = Array.isArray(payload.data) ? payload.data : [];
if (rows.length !== texts.length) throw new Error('Embedding-Dienst lieferte eine unerwartete Anzahl an Vektoren.');
return rows.map(row => {
const vector = Array.isArray(row.embedding) ? row.embedding : [];
if (vector.length !== dimensions) throw new Error(`Embedding lieferte ${vector.length} Dimensionen, erwartet ${dimensions}.`);
return vector;
});
}
async function qdrant(path, method, body) {
return fetchRetry(`${qdrantUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...(qdrantApiKey ? { 'api-key': qdrantApiKey } : {}) },
body: body === undefined ? undefined : JSON.stringify(body)
});
}
async function ensureCollection() {
const info = await qdrant(`/collections/${encodeURIComponent(collection)}`, 'GET');
if (info.ok) {
const data = await info.json().catch(() => null);
const size = data?.result?.config?.params?.vectors?.size;
if (size !== undefined && size !== dimensions) {
throw new Error(`Bestehende Collection hat Vektorgröße ${size}, erwartet ${dimensions}.`);
}
return;
}
if (info.status !== 404) throw new Error(`Qdrant antwortete mit HTTP ${info.status}.`);
const created = await qdrant(`/collections/${encodeURIComponent(collection)}`, 'PUT', { vectors: { size: dimensions, distance: 'Cosine' } });
if (!created.ok) throw new Error(`Collection konnte nicht angelegt werden (HTTP ${created.status}).`);
}
const PAYLOAD_FIELDS = [
'unit_id', 'unit_version', 'regulation_id', 'regulation_title',
'control_id', 'control_title', 'language', 'catalog_version',
'chunk_id', 'chunk_text', 'authoritative_text', 'source_uri'
];
function toPayload(row) {
const payload = {};
for (const field of PAYLOAD_FIELDS) {
if (row[field] !== undefined) payload[field] = row[field];
}
return payload;
}
async function main() {
const raw = await readFile(catalogFile, 'utf8');
const lines = raw.split(/\r?\n/).filter(line => line.trim());
const rows = [];
const units = new Set();
lines.forEach((line, index) => {
let row;
try {
row = JSON.parse(line);
} catch {
throw new Error(`Zeile ${index + 1} ist kein gültiges JSON.`);
}
if (!row.unit_id || !row.chunk_id || !row.embed_text?.trim()) {
throw new Error(`Zeile ${index + 1} fehlen Pflichtfelder (unit_id, chunk_id, embed_text).`);
}
if (expectedCatalogVersion && row.catalog_version !== expectedCatalogVersion) {
throw new Error(`Zeile ${index + 1} hat catalog_version, die nicht ${expectedCatalogVersion} entspricht.`);
}
units.add(row.unit_id);
rows.push(row);
});
if (rows.length === 0) throw new Error('Die Katalogdatei enthält keine Zeilen.');
await ensureCollection();
let upserted = 0;
for (let start = 0; start < rows.length; start += batchSize) {
const batch = rows.slice(start, start + batchSize);
const vectors = await embed(batch.map(row => row.embed_text));
const points = batch.map((row, i) => ({ id: pointId(row.chunk_id), vector: vectors[i], payload: toPayload(row) }));
const response = await qdrant(`/collections/${encodeURIComponent(collection)}/points?wait=true`, 'PUT', { points });
if (!response.ok) throw new Error(`Upsert schlug fehl (HTTP ${response.status}).`);
upserted += points.length;
process.stdout.write(` upserted ${upserted}/${rows.length}\r`);
}
console.log(`\nIngestion abgeschlossen: ${upserted} Chunk(s) aus ${units.size} Unit(s) in Collection "${collection}" (Dim ${dimensions}).`);
}
main().catch(error => {
console.error('Ingestion fehlgeschlagen:', error instanceof Error ? error.message : error);
process.exitCode = 1;
});

View File

@ -0,0 +1,124 @@
// Minimal Qdrant REST client (backend-only, dependency-free). Only the
// operations the ingestion script and retrieval service need are implemented.
//
// Never logs payload text or vector values.
export interface QdrantClientConfig {
url: string;
apiKey?: string;
collection: string;
requestTimeoutMs: number;
}
export type QdrantDistance = 'Cosine' | 'Dot' | 'Euclid';
export interface QdrantCollectionInfo {
exists: boolean;
vectorSize?: number;
distance?: string;
}
export interface QdrantPoint {
id: string | number;
vector: number[];
payload: Record<string, unknown>;
}
export interface QdrantSearchHit {
id: string | number;
score: number;
payload: Record<string, unknown>;
}
export class QdrantError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = 'QdrantError';
}
}
export class QdrantClient {
constructor(private readonly config: QdrantClientConfig) {}
private async request(path: string, method: string, body?: unknown): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
try {
return await fetch(`${this.config.url}${path}`, {
method,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(this.config.apiKey ? { 'api-key': this.config.apiKey } : {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
} catch (error) {
const aborted = error instanceof Error && error.name === 'AbortError';
throw new QdrantError(aborted ? 'qdrant_timeout' : 'qdrant_unreachable',
aborted ? 'Qdrant hat nicht rechtzeitig geantwortet.' : 'Qdrant ist nicht erreichbar.');
} finally {
clearTimeout(timeout);
}
}
async collectionInfo(): Promise<QdrantCollectionInfo> {
const response = await this.request(`/collections/${encodeURIComponent(this.config.collection)}`, 'GET');
if (response.status === 404) return { exists: false };
if (!response.ok) {
throw new QdrantError('qdrant_http_error', `Qdrant antwortete mit HTTP ${response.status}.`);
}
const payload = await response.json().catch(() => null) as
| { result?: { config?: { params?: { vectors?: { size?: number; distance?: string } } } } }
| null;
const vectors = payload?.result?.config?.params?.vectors;
return { exists: true, vectorSize: vectors?.size, distance: vectors?.distance };
}
async ensureCollection(size: number, distance: QdrantDistance): Promise<void> {
const info = await this.collectionInfo();
if (info.exists) {
if (info.vectorSize !== undefined && info.vectorSize !== size) {
throw new QdrantError('dimension_mismatch',
`Die Qdrant-Collection hat Vektorgröße ${info.vectorSize}, erwartet wurden ${size}.`);
}
return;
}
const response = await this.request(
`/collections/${encodeURIComponent(this.config.collection)}`,
'PUT',
{ vectors: { size, distance } }
);
if (!response.ok) {
throw new QdrantError('qdrant_create_failed', `Die Qdrant-Collection konnte nicht angelegt werden (HTTP ${response.status}).`);
}
}
async upsertPoints(points: QdrantPoint[]): Promise<void> {
if (points.length === 0) return;
const response = await this.request(
`/collections/${encodeURIComponent(this.config.collection)}/points?wait=true`,
'PUT',
{ points }
);
if (!response.ok) {
throw new QdrantError('qdrant_upsert_failed', `Qdrant-Upsert schlug fehl (HTTP ${response.status}).`);
}
}
async search(vector: number[], limit: number): Promise<QdrantSearchHit[]> {
const response = await this.request(
`/collections/${encodeURIComponent(this.config.collection)}/points/search`,
'POST',
{ vector, limit, with_payload: true }
);
if (!response.ok) {
throw new QdrantError('qdrant_search_failed', `Qdrant-Suche schlug fehl (HTTP ${response.status}).`);
}
const payload = await response.json().catch(() => null) as
| { result?: Array<{ id: string | number; score: number; payload?: Record<string, unknown> }> }
| null;
const hits = Array.isArray(payload?.result) ? payload!.result : [];
return hits.map(hit => ({ id: hit.id, score: hit.score, payload: hit.payload ?? {} }));
}
}

View File

@ -0,0 +1,36 @@
// Builds the regulation-mapping runtime from the environment: the real
// retrieval service when configured, otherwise a static disabled/misconfigured
// stub. Kept separate from regulation-mapping.ts to avoid an import cycle with
// the service module.
import {
disabledRegulationMapping,
readRegulationMappingConfig,
RegulationMappingUnavailableError,
type RegulationMapping,
type RegulationMappingEnvironment
} from './regulation-mapping';
import { RegulationRetrievalService } from './regulation-retrieval-service';
class MisconfiguredRegulationMapping implements RegulationMapping {
constructor(private readonly detailCode: string) {}
private report() {
return { capability: 'misconfigured' as const, detailCode: this.detailCode };
}
cachedCapability() {
return this.report();
}
async refreshCapability() {
return this.report();
}
async retrieve(): Promise<never> {
throw new RegulationMappingUnavailableError(this.report());
}
}
export function createRegulationMapping(env: RegulationMappingEnvironment = process.env as RegulationMappingEnvironment): RegulationMapping {
const result = readRegulationMappingConfig(env);
if (result.state === 'disabled') return disabledRegulationMapping();
if (result.state === 'error') return new MisconfiguredRegulationMapping(result.detailCode);
return new RegulationRetrievalService(result.config);
}

View File

@ -0,0 +1,190 @@
// Regulation mapping — configuration, capability model and runtime factory.
//
// This is the "ingestion + retrieval" slice: it consumes a Qdrant collection of
// embedded regulation-catalog chunks and, for a given finding definition,
// retrieves the best-matching atomic regulation units. It performs no LLM
// selection and persists nothing. The feature stays DISABLED unless an internal
// Qdrant endpoint and an OpenAI-compatible embedding endpoint are configured.
//
// Security: every call is backend-only, aimed at the explicitly configured
// internal endpoints. Query text, catalog text and vector values are never
// logged. Only ids, counts, versions, durations and safe error codes are safe
// to surface.
import type {
RegulationDefinitionInput,
RegulationRetrievalResult,
RegulationCapability,
RegulationCapabilityReport
} from './regulation-retrieval-types';
export type {
RegulationDefinitionInput,
RegulationRetrievalResult,
RegulationCapability,
RegulationCapabilityReport
} from './regulation-retrieval-types';
export interface RegulationMappingConfig {
qdrantUrl: string;
qdrantApiKey?: string;
collection: string;
embeddingBaseUrl: string;
embeddingModel: string;
embeddingApiKey?: string;
embeddingDimensions: number;
catalogVersion: string;
queryPrefix?: string;
passagePrefix?: string;
requestTimeoutMs: number;
retrievalChunkLimit: number;
candidateUnitLimit: number;
maximumQueryCharacters: number;
}
export type RegulationMappingConfigResult =
| { state: 'disabled' }
| { state: 'error'; detailCode: string; message: string }
| { state: 'ready'; config: RegulationMappingConfig };
/** Thrown by retrieve() when the feature is not in a ready state. */
export class RegulationMappingUnavailableError extends Error {
constructor(readonly report: RegulationCapabilityReport) {
super('regulation mapping unavailable');
this.name = 'RegulationMappingUnavailableError';
}
}
/** Runtime contract used by the HTTP layer. Two implementations exist: a static
* disabled/misconfigured stub, and the real retrieval service. */
export interface RegulationMapping {
/** Last known capability without performing any network call. */
cachedCapability(): RegulationCapabilityReport;
/** Validate the live backend state and refresh the cached capability. */
refreshCapability(): Promise<RegulationCapabilityReport>;
/** Retrieve candidate regulation units; throws RegulationMappingUnavailableError when not ready. */
retrieve(input: RegulationDefinitionInput): Promise<RegulationRetrievalResult>;
}
export interface RegulationMappingEnvironment {
ASSESSOR_QDRANT_URL?: string;
ASSESSOR_QDRANT_API_KEY?: string;
ASSESSOR_QDRANT_COLLECTION?: string;
ASSESSOR_EMBEDDING_BASE_URL?: string;
ASSESSOR_EMBEDDING_MODEL?: string;
ASSESSOR_EMBEDDING_API_KEY?: string;
ASSESSOR_EMBEDDING_DIMENSIONS?: string;
ASSESSOR_EMBEDDING_QUERY_PREFIX?: string;
ASSESSOR_EMBEDDING_PASSAGE_PREFIX?: string;
ASSESSOR_REGMAP_CATALOG_VERSION?: string;
ASSESSOR_REGMAP_TIMEOUT_MS?: string;
ASSESSOR_REGMAP_CHUNK_LIMIT?: string;
ASSESSOR_REGMAP_CANDIDATE_LIMIT?: string;
ASSESSOR_REGMAP_MAX_QUERY_CHARS?: string;
}
function trimmed(value: string | undefined): string | undefined {
const result = value?.trim();
return result ? result : undefined;
}
function boundedInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string
): number {
if (value === undefined || value.trim() === '') return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new Error(`${name} muss eine Ganzzahl zwischen ${minimum} und ${maximum} sein.`);
}
return parsed;
}
function assertUrl(value: string, name: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} ist keine gültige URL.`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`${name} muss http:// oder https:// verwenden.`);
}
return value.replace(/\/+$/, '');
}
const DEFAULT_COLLECTION = 'regulation_catalog_v1';
/**
* Parse the regulation-mapping configuration from environment variables.
* - The embedding base URL is the opt-in signal: without it the feature is
* intentionally disabled (the Qdrant URL may be pre-wired by deployment
* compose and is harmless on its own).
* - When the embedding endpoint is set but other required values are missing or
* invalid, the feature is misconfigured (error) rather than silently off.
*/
export function readRegulationMappingConfig(
env: RegulationMappingEnvironment = process.env as RegulationMappingEnvironment
): RegulationMappingConfigResult {
const embeddingBaseUrl = trimmed(env.ASSESSOR_EMBEDDING_BASE_URL);
if (!embeddingBaseUrl) return { state: 'disabled' };
const qdrantUrl = trimmed(env.ASSESSOR_QDRANT_URL);
try {
if (!qdrantUrl) throw new Error('ASSESSOR_QDRANT_URL ist erforderlich.');
const embeddingModel = trimmed(env.ASSESSOR_EMBEDDING_MODEL);
if (!embeddingModel) throw new Error('ASSESSOR_EMBEDDING_MODEL ist erforderlich.');
const catalogVersion = trimmed(env.ASSESSOR_REGMAP_CATALOG_VERSION);
if (!catalogVersion) throw new Error('ASSESSOR_REGMAP_CATALOG_VERSION ist erforderlich.');
const embeddingDimensions = boundedInteger(
env.ASSESSOR_EMBEDDING_DIMENSIONS, 0, 8, 8192, 'ASSESSOR_EMBEDDING_DIMENSIONS'
);
if (embeddingDimensions === 0) throw new Error('ASSESSOR_EMBEDDING_DIMENSIONS ist erforderlich.');
const config: RegulationMappingConfig = {
qdrantUrl: assertUrl(qdrantUrl, 'ASSESSOR_QDRANT_URL'),
qdrantApiKey: trimmed(env.ASSESSOR_QDRANT_API_KEY),
collection: trimmed(env.ASSESSOR_QDRANT_COLLECTION) ?? DEFAULT_COLLECTION,
embeddingBaseUrl: assertUrl(embeddingBaseUrl, 'ASSESSOR_EMBEDDING_BASE_URL'),
embeddingModel,
embeddingApiKey: trimmed(env.ASSESSOR_EMBEDDING_API_KEY),
embeddingDimensions,
catalogVersion,
queryPrefix: trimmed(env.ASSESSOR_EMBEDDING_QUERY_PREFIX),
passagePrefix: trimmed(env.ASSESSOR_EMBEDDING_PASSAGE_PREFIX),
requestTimeoutMs: boundedInteger(env.ASSESSOR_REGMAP_TIMEOUT_MS, 30_000, 1_000, 120_000, 'ASSESSOR_REGMAP_TIMEOUT_MS'),
retrievalChunkLimit: boundedInteger(env.ASSESSOR_REGMAP_CHUNK_LIMIT, 20, 1, 200, 'ASSESSOR_REGMAP_CHUNK_LIMIT'),
candidateUnitLimit: boundedInteger(env.ASSESSOR_REGMAP_CANDIDATE_LIMIT, 5, 1, 50, 'ASSESSOR_REGMAP_CANDIDATE_LIMIT'),
maximumQueryCharacters: boundedInteger(env.ASSESSOR_REGMAP_MAX_QUERY_CHARS, 4_000, 200, 20_000, 'ASSESSOR_REGMAP_MAX_QUERY_CHARS')
};
return { state: 'ready', config };
} catch (error) {
return {
state: 'error',
detailCode: 'config_incomplete',
message: error instanceof Error ? error.message : 'Regulation-Mapping-Konfiguration ist ungültig.'
};
}
}
/** A static, non-networking runtime used when the feature is disabled or its
* configuration is invalid. */
class InertRegulationMapping implements RegulationMapping {
constructor(private readonly report: RegulationCapabilityReport) {}
cachedCapability(): RegulationCapabilityReport {
return this.report;
}
async refreshCapability(): Promise<RegulationCapabilityReport> {
return this.report;
}
async retrieve(): Promise<RegulationRetrievalResult> {
throw new RegulationMappingUnavailableError(this.report);
}
}
export function disabledRegulationMapping(): RegulationMapping {
return new InertRegulationMapping({ capability: 'disabled' });
}

View File

@ -0,0 +1,151 @@
// Regulation retrieval service: for a finding definition, embed a definition-level
// query, cosine-search the Qdrant catalog, group hits by atomic unit, and return
// the best candidate units. No LLM selection, no persistence.
//
// Never logs query text, catalog text or vectors — only counts / codes.
import {
RegulationMappingUnavailableError,
type RegulationCapabilityReport,
type RegulationMapping,
type RegulationMappingConfig
} from './regulation-mapping';
import type { RegulationDefinitionInput, RegulationRetrievalResult, RegulationCandidateUnit } from './regulation-retrieval-types';
import { createEmbeddingClient, type EmbeddingClient } from './embedding-client';
import { QdrantClient, type QdrantSearchHit } from './qdrant-client';
export interface RegulationRetrievalDependencies {
embeddingClient?: EmbeddingClient;
qdrantClient?: Pick<QdrantClient, 'collectionInfo' | 'search'>;
}
function stringField(payload: Record<string, unknown>, key: string): string | undefined {
const value = payload[key];
return typeof value === 'string' && value.trim() ? value : undefined;
}
export class RegulationRetrievalService implements RegulationMapping {
private readonly embedding: EmbeddingClient;
private readonly qdrant: Pick<QdrantClient, 'collectionInfo' | 'search'>;
private report: RegulationCapabilityReport;
constructor(
private readonly config: RegulationMappingConfig,
dependencies: RegulationRetrievalDependencies = {}
) {
this.embedding = dependencies.embeddingClient ?? createEmbeddingClient({
baseUrl: config.embeddingBaseUrl,
model: config.embeddingModel,
apiKey: config.embeddingApiKey,
dimensions: config.embeddingDimensions,
queryPrefix: config.queryPrefix,
passagePrefix: config.passagePrefix,
requestTimeoutMs: config.requestTimeoutMs
});
this.qdrant = dependencies.qdrantClient ?? new QdrantClient({
url: config.qdrantUrl,
apiKey: config.qdrantApiKey,
collection: config.collection,
requestTimeoutMs: config.requestTimeoutMs
});
// Not yet validated against the live backend.
this.report = this.reportWith('misconfigured', 'not_validated');
}
private reportWith(capability: RegulationCapabilityReport['capability'], detailCode?: string): RegulationCapabilityReport {
return {
capability,
catalogVersion: this.config.catalogVersion,
embeddingModel: this.config.embeddingModel,
collection: this.config.collection,
detailCode
};
}
cachedCapability(): RegulationCapabilityReport {
return this.report;
}
async refreshCapability(): Promise<RegulationCapabilityReport> {
try {
const info = await this.qdrant.collectionInfo();
if (!info.exists) {
this.report = this.reportWith('misconfigured', 'collection_missing');
} else if (info.vectorSize !== undefined && info.vectorSize !== this.config.embeddingDimensions) {
this.report = this.reportWith('misconfigured', 'dimension_mismatch');
} else if (info.distance && info.distance !== 'Cosine') {
this.report = this.reportWith('misconfigured', 'distance_mismatch');
} else {
// Verify the embedding endpoint/model end to end with a synthetic probe.
await this.embedding.embed(['probe'], 'query');
this.report = this.reportWith('ready');
}
} catch (error) {
this.report = this.reportWith('misconfigured', safeErrorCode(error));
}
return this.report;
}
async retrieve(input: RegulationDefinitionInput): Promise<RegulationRetrievalResult> {
if (this.report.capability !== 'ready') {
const refreshed = await this.refreshCapability();
if (refreshed.capability !== 'ready') throw new RegulationMappingUnavailableError(refreshed);
}
const query = this.buildQuery(input);
let hits: QdrantSearchHit[];
try {
const [vector] = await this.embedding.embed([query], 'query');
hits = await this.qdrant.search(vector, this.config.retrievalChunkLimit);
} catch (error) {
this.report = this.reportWith('misconfigured', safeErrorCode(error));
throw new RegulationMappingUnavailableError(this.report);
}
return {
catalogVersion: this.config.catalogVersion,
embeddingModel: this.config.embeddingModel,
candidates: groupHitsByUnit(hits, this.config.candidateUnitLimit)
};
}
private buildQuery(input: RegulationDefinitionInput): string {
const parts = [
input.title,
input.description,
input.recommendation ? `Remediation: ${input.recommendation}` : undefined,
input.category ? `Category: ${input.category}` : undefined
].filter((part): part is string => typeof part === 'string' && part.trim().length > 0);
return parts.join('. ').slice(0, this.config.maximumQueryCharacters);
}
}
function safeErrorCode(error: unknown): string {
const code = (error as { code?: unknown })?.code;
return typeof code === 'string' && code ? code : 'validation_failed';
}
/** Group chunk hits back to their atomic unit, scoring each unit by its best
* chunk, then return the top-N units in deterministic order. */
export function groupHitsByUnit(hits: QdrantSearchHit[], limit: number): RegulationCandidateUnit[] {
const byUnit = new Map<string, RegulationCandidateUnit>();
for (const hit of hits) {
const unitId = stringField(hit.payload, 'unit_id');
if (!unitId) continue;
const existing = byUnit.get(unitId);
if (existing && existing.score >= hit.score) continue;
byUnit.set(unitId, {
unitId,
regulationId: stringField(hit.payload, 'regulation_id'),
regulationTitle: stringField(hit.payload, 'regulation_title'),
controlId: stringField(hit.payload, 'control_id'),
controlTitle: stringField(hit.payload, 'control_title'),
title: stringField(hit.payload, 'control_title') ?? stringField(hit.payload, 'title'),
score: hit.score,
bestChunkId: stringField(hit.payload, 'chunk_id')
});
}
return [...byUnit.values()]
.sort((a, b) => (b.score - a.score) || a.unitId.localeCompare(b.unitId))
.slice(0, limit);
}

View File

@ -0,0 +1,10 @@
// Re-export the shared regulation contracts so server-side regmap modules have a
// local import path. The single source of truth lives in the API contract.
export type {
RegulationDefinitionInput,
RegulationCandidateUnit,
RegulationMappingRetrieveResponse as RegulationRetrievalResult,
RegulationMappingCapabilityReport as RegulationCapabilityReport,
RegulationMappingCapability as RegulationCapability
} from '../../src/api/assessor-api-contract';

View File

@ -17,6 +17,52 @@ import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview'
export const ASSESSOR_API_PREFIX = '/api/v1';
export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request';
export type RegulationMappingCapability = 'disabled' | 'misconfigured' | 'ready';
/** Content-free capability descriptor safe to expose on health and API. */
export interface RegulationMappingCapabilityReport {
capability: RegulationMappingCapability;
catalogVersion?: string;
embeddingModel?: string;
collection?: string;
/** Safe, stable reason code — never a free-form message with sensitive content. */
detailCode?: string;
}
/** Definition-level retrieval input. Only definition-level fields are used —
* never occurrence evidence, assets, tenant names or project metadata. */
export interface RegulationDefinitionInput {
title: string;
description?: string;
recommendation?: string;
category?: string;
ruleId?: string;
frameworkReferences?: string[];
}
/** One retrieved atomic regulation unit (control / article / clause). */
export interface RegulationCandidateUnit {
unitId: string;
regulationId?: string;
regulationTitle?: string;
controlId?: string;
controlTitle?: string;
title?: string;
/** Cosine similarity of the best matching chunk of this unit (0..1). */
score: number;
bestChunkId?: string;
}
export interface RegulationMappingRetrieveRequest {
definition: RegulationDefinitionInput;
}
export interface RegulationMappingRetrieveResponse {
catalogVersion: string;
embeddingModel: string;
candidates: RegulationCandidateUnit[];
}
export interface AssessorHealthResponse {
status: 'ok';
application: 'ASSESSOR';
@ -25,6 +71,7 @@ export interface AssessorHealthResponse {
developmentInstanceId?: string;
repositoryRevision: number;
persistence: 'memory' | 'postgresql';
regulationMapping?: RegulationMappingCapabilityReport;
}
export interface AssessorApiErrorBody {

View File

@ -0,0 +1,92 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'node:http';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import {
RegulationMappingUnavailableError,
type RegulationMapping,
type RegulationCapabilityReport
} from '../../server/regmap/regulation-mapping';
function readyMapping(): RegulationMapping {
const report: RegulationCapabilityReport = { capability: 'ready', catalogVersion: 'TEST/2024', embeddingModel: 'test-embed', collection: 'c' };
return {
cachedCapability: () => report,
refreshCapability: async () => report,
retrieve: async () => ({ catalogVersion: 'TEST/2024', embeddingModel: 'test-embed', candidates: [{ unitId: '8.24', controlId: '8.24', score: 0.9 }] })
};
}
function unavailableMapping(): RegulationMapping {
const report: RegulationCapabilityReport = { capability: 'misconfigured', detailCode: 'collection_missing' };
return {
cachedCapability: () => report,
refreshCapability: async () => report,
retrieve: async () => { throw new RegulationMappingUnavailableError(report); }
};
}
async function startServer(regulationMapping: RegulationMapping): Promise<{ server: Server; baseUrl: string; directory: string }> {
const directory = await mkdtemp(join(tmpdir(), 'assessor-regmap-'));
await writeFile(join(directory, 'index.html'), '<!doctype html><title>ASSESSOR</title>', 'utf8');
const server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, regulationMapping });
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address() as AddressInfo;
return { server, baseUrl: `http://127.0.0.1:${address.port}`, directory };
}
describe('regulation mapping HTTP endpoints', () => {
let server: Server;
let baseUrl: string;
let directory: string;
afterEach(async () => {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
await rm(directory, { recursive: true, force: true });
});
const retrieve = (body: unknown) => fetch(`${baseUrl}/api/v1/regulation-mapping/retrieve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify(body)
});
it('exposes the capability on health and the dedicated endpoint', async () => {
({ server, baseUrl, directory } = await startServer(readyMapping()));
const health = await (await fetch(`${baseUrl}/api/v1/health`)).json();
expect(health.regulationMapping).toMatchObject({ capability: 'ready', catalogVersion: 'TEST/2024' });
const capability = await (await fetch(`${baseUrl}/api/v1/regulation-mapping/capability`)).json();
expect(capability).toMatchObject({ capability: 'ready', embeddingModel: 'test-embed' });
});
it('retrieves candidate units when ready', async () => {
({ server, baseUrl, directory } = await startServer(readyMapping()));
const response = await retrieve({ definition: { title: 'RC4 enabled on DC' } });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ candidates: [{ unitId: '8.24' }] });
});
it('rejects a retrieve request without a title', async () => {
({ server, baseUrl, directory } = await startServer(readyMapping()));
const response = await retrieve({ definition: { description: 'no title' } });
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: { code: 'invalid_regulation_definition' } });
});
it('returns 503 with the capability when the feature is unavailable', async () => {
({ server, baseUrl, directory } = await startServer(unavailableMapping()));
const response = await retrieve({ definition: { title: 'anything' } });
expect(response.status).toBe(503);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'regulation_mapping_unavailable' },
capability: { capability: 'misconfigured', detailCode: 'collection_missing' }
});
});
});

View File

@ -0,0 +1,182 @@
import { describe, expect, it, vi } from 'vitest';
import {
RegulationMappingUnavailableError,
readRegulationMappingConfig,
type RegulationMappingConfig
} from '../../server/regmap/regulation-mapping';
import { RegulationRetrievalService, groupHitsByUnit } from '../../server/regmap/regulation-retrieval-service';
import type { EmbeddingClient } from '../../server/regmap/embedding-client';
import type { QdrantCollectionInfo, QdrantSearchHit } from '../../server/regmap/qdrant-client';
const baseConfig: RegulationMappingConfig = {
qdrantUrl: 'http://qdrant:6333',
collection: 'regulation_catalog_v1',
embeddingBaseUrl: 'http://embed:8080/v1',
embeddingModel: 'test-embed',
embeddingDimensions: 3,
catalogVersion: 'TEST/2024',
requestTimeoutMs: 1000,
retrievalChunkLimit: 20,
candidateUnitLimit: 5,
maximumQueryCharacters: 4000
};
function fakeEmbedding(vector: number[] = [1, 0, 0]): EmbeddingClient {
return { embed: vi.fn(async (texts: string[]) => texts.map(() => vector)) };
}
function fakeQdrant(info: QdrantCollectionInfo, hits: QdrantSearchHit[] = []) {
return {
collectionInfo: vi.fn(async () => info),
search: vi.fn(async () => hits)
};
}
const hit = (unitId: string, score: number, extra: Record<string, unknown> = {}): QdrantSearchHit => ({
id: `${unitId}-point`,
score,
payload: { unit_id: unitId, control_id: unitId, control_title: `Control ${unitId}`, chunk_id: `${unitId}#1`, ...extra }
});
describe('readRegulationMappingConfig', () => {
it('is disabled without an embedding endpoint', () => {
expect(readRegulationMappingConfig({})).toEqual({ state: 'disabled' });
expect(readRegulationMappingConfig({ ASSESSOR_QDRANT_URL: 'http://qdrant:6333' })).toEqual({ state: 'disabled' });
});
it('is misconfigured when the embedding endpoint is set but required values are missing', () => {
const result = readRegulationMappingConfig({ ASSESSOR_EMBEDDING_BASE_URL: 'http://embed:8080/v1' });
expect(result.state).toBe('error');
if (result.state === 'error') expect(result.detailCode).toBe('config_incomplete');
});
it('parses a complete configuration and applies defaults', () => {
const result = readRegulationMappingConfig({
ASSESSOR_QDRANT_URL: 'http://qdrant:6333/',
ASSESSOR_EMBEDDING_BASE_URL: 'http://embed:8080/v1/',
ASSESSOR_EMBEDDING_MODEL: 'bge-m3',
ASSESSOR_EMBEDDING_DIMENSIONS: '1024',
ASSESSOR_REGMAP_CATALOG_VERSION: 'DIN EN ISO/IEC 27002:2024-01'
});
expect(result.state).toBe('ready');
if (result.state === 'ready') {
expect(result.config.qdrantUrl).toBe('http://qdrant:6333');
expect(result.config.embeddingBaseUrl).toBe('http://embed:8080/v1');
expect(result.config.collection).toBe('regulation_catalog_v1');
expect(result.config.embeddingDimensions).toBe(1024);
expect(result.config.candidateUnitLimit).toBe(5);
}
});
it('rejects an invalid embedding dimension', () => {
const result = readRegulationMappingConfig({
ASSESSOR_QDRANT_URL: 'http://qdrant:6333',
ASSESSOR_EMBEDDING_BASE_URL: 'http://embed:8080/v1',
ASSESSOR_EMBEDDING_MODEL: 'bge-m3',
ASSESSOR_EMBEDDING_DIMENSIONS: 'not-a-number',
ASSESSOR_REGMAP_CATALOG_VERSION: 'X'
});
expect(result.state).toBe('error');
});
});
describe('RegulationRetrievalService capability', () => {
it('reports collection_missing when the collection does not exist', async () => {
const service = new RegulationRetrievalService(baseConfig, {
embeddingClient: fakeEmbedding(),
qdrantClient: fakeQdrant({ exists: false })
});
const report = await service.refreshCapability();
expect(report).toMatchObject({ capability: 'misconfigured', detailCode: 'collection_missing' });
});
it('reports dimension_mismatch when the stored vector size differs', async () => {
const service = new RegulationRetrievalService(baseConfig, {
embeddingClient: fakeEmbedding(),
qdrantClient: fakeQdrant({ exists: true, vectorSize: 768, distance: 'Cosine' })
});
expect(await service.refreshCapability()).toMatchObject({ capability: 'misconfigured', detailCode: 'dimension_mismatch' });
});
it('reports distance_mismatch when the collection is not cosine', async () => {
const service = new RegulationRetrievalService(baseConfig, {
embeddingClient: fakeEmbedding(),
qdrantClient: fakeQdrant({ exists: true, vectorSize: 3, distance: 'Dot' })
});
expect(await service.refreshCapability()).toMatchObject({ capability: 'misconfigured', detailCode: 'distance_mismatch' });
});
it('is ready when the collection matches and the embedding probe succeeds', async () => {
const service = new RegulationRetrievalService(baseConfig, {
embeddingClient: fakeEmbedding(),
qdrantClient: fakeQdrant({ exists: true, vectorSize: 3, distance: 'Cosine' })
});
expect(await service.refreshCapability()).toMatchObject({ capability: 'ready', catalogVersion: 'TEST/2024', embeddingModel: 'test-embed' });
});
it('surfaces an embedding failure as misconfigured', async () => {
const embedding: EmbeddingClient = { embed: vi.fn(async () => { throw Object.assign(new Error('down'), { code: 'embedding_unreachable' }); }) };
const service = new RegulationRetrievalService(baseConfig, {
embeddingClient: embedding,
qdrantClient: fakeQdrant({ exists: true, vectorSize: 3, distance: 'Cosine' })
});
expect(await service.refreshCapability()).toMatchObject({ capability: 'misconfigured', detailCode: 'embedding_unreachable' });
});
});
describe('groupHitsByUnit', () => {
it('groups chunks to their unit, scoring each unit by its best chunk', () => {
const grouped = groupHitsByUnit([hit('A', 0.5), hit('B', 0.9), hit('A', 0.7)], 5);
expect(grouped.map(c => [c.unitId, c.score])).toEqual([['B', 0.9], ['A', 0.7]]);
});
it('is deterministic on ties and honours the unit limit', () => {
const grouped = groupHitsByUnit([hit('B', 0.8), hit('A', 0.8), hit('C', 0.4)], 2);
expect(grouped.map(c => c.unitId)).toEqual(['A', 'B']);
});
it('ignores hits without a unit id', () => {
const grouped = groupHitsByUnit([{ id: 'x', score: 0.9, payload: {} }, hit('A', 0.3)], 5);
expect(grouped.map(c => c.unitId)).toEqual(['A']);
});
});
describe('RegulationRetrievalService retrieve', () => {
it('embeds the query and returns grouped candidate units', async () => {
const embedding = fakeEmbedding([0, 1, 0]);
const qdrant = fakeQdrant({ exists: true, vectorSize: 3, distance: 'Cosine' }, [hit('8.24', 0.91), hit('5.1', 0.42)]);
const service = new RegulationRetrievalService(baseConfig, { embeddingClient: embedding, qdrantClient: qdrant });
await service.refreshCapability();
const result = await service.retrieve({ title: 'RC4 enabled on DC', description: 'Weak cipher permitted' });
expect(result.catalogVersion).toBe('TEST/2024');
expect(result.candidates.map(c => c.unitId)).toEqual(['8.24', '5.1']);
expect(result.candidates[0]).toMatchObject({ controlId: '8.24', controlTitle: 'Control 8.24', bestChunkId: '8.24#1' });
expect(embedding.embed).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining('RC4 enabled on DC')]), 'query');
});
it('throws RegulationMappingUnavailableError when not ready', async () => {
const service = new RegulationRetrievalService(baseConfig, {
embeddingClient: fakeEmbedding(),
qdrantClient: fakeQdrant({ exists: false })
});
await expect(service.retrieve({ title: 'anything' })).rejects.toBeInstanceOf(RegulationMappingUnavailableError);
});
it('does not log the query text', async () => {
const logs: unknown[] = [];
const spies = [
vi.spyOn(console, 'log').mockImplementation((...args) => { logs.push(...args); }),
vi.spyOn(console, 'error').mockImplementation((...args) => { logs.push(...args); }),
vi.spyOn(console, 'warn').mockImplementation((...args) => { logs.push(...args); })
];
const service = new RegulationRetrievalService(baseConfig, {
embeddingClient: fakeEmbedding([0, 1, 0]),
qdrantClient: fakeQdrant({ exists: true, vectorSize: 3, distance: 'Cosine' }, [hit('8.24', 0.91)])
});
await service.refreshCapability();
await service.retrieve({ title: 'SECRET-MARKER-TITLE', description: 'SECRET-MARKER-DESC' });
spies.forEach(spy => spy.mockRestore());
expect(logs.join(' ')).not.toContain('SECRET-MARKER');
});
});

View File

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