Assessor_2.0/tests/compliance/bsi-direct-mapping.test.ts

275 lines
20 KiB
TypeScript

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