145 lines
8.8 KiB
TypeScript
145 lines
8.8 KiB
TypeScript
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']);
|
|
});
|
|
});
|