Assessor_2.0/src/compliance/bsi-grundschutz.ts

285 lines
14 KiB
TypeScript

/**
* BSI Grundschutz++ for the Compliance Engine (Phase 13F, user decision 2026-10-08): the Kernel requirements of
* the BSI Stand-der-Technik-Bibliothek and the official BSI mapping ISO/IEC 27001:2022 Annex A -> Grundschutz++,
* converted into compact, version-pinned snapshots like the MITRE reference catalogs. Source prose is kept as
* published, including its OSCAL parameter placeholders; `bsiRequirementText` resolves them for display.
*/
export const BSI_KERNEL_CLASS = 'BSI-Stand-der-Technik-Kernel-G0';
export const BSI_NOTICE = 'Source: German Federal Office for Information Security (BSI), Stand-der-Technik-Bibliothek, '
+ 'https://github.com/BSI-Bund/Stand-der-Technik-Bibliothek. License: Creative Commons Attribution-ShareAlike 4.0 International '
+ '(CC BY-SA 4.0), https://creativecommons.org/licenses/by-sa/4.0/. Compacted; the requirement content is unchanged.';
export type BsiCrosswalkRelationship = 'equivalent-to' | 'subset-of' | 'superset-of' | 'intersects-with';
export interface BsiRequirement {
id: string;
title: string;
/** Top-level practice of the requirement, for example `KONF`. */
practice: string;
parentId?: string;
/** Statement prose exactly as published, including `{{ insert: param, <id> }}` placeholders. */
statement: string;
modalVerb?: string;
guidance?: string;
securityLevel?: string;
effortLevel?: string;
/** Parameter id -> its published value, or its label where no value is set. */
params?: Record<string, string>;
}
export interface BsiKernelCatalog {
schemaVersion: 1;
framework: 'bsi-grundschutz-plus-plus';
catalogClass: typeof BSI_KERNEL_CLASS;
title: string;
version: string;
sourceUrl: string;
sourceSha256: string;
license: string;
practices: Array<{ id: string; title: string }>;
requirements: BsiRequirement[];
}
export interface BsiCrosswalkEntry {
uuid: string;
isoControlId: string;
relationship: BsiCrosswalkRelationship;
targets: string[];
}
export interface BsiIsoCrosswalk {
schemaVersion: 1;
framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus';
title: string;
version: string;
sourceUrl: string;
sourceSha256: string;
license: string;
/** Only the mapping whose target is the Kernel catalog; the Methodik mapping is outside the decided scope. */
targetCatalog: string;
entries: BsiCrosswalkEntry[];
}
type ObjectValue = Record<string, unknown>;
const object = (value: unknown): ObjectValue => value && typeof value === 'object' && !Array.isArray(value) ? value as ObjectValue : {};
const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
const text = (value: unknown): string => typeof value === 'string' ? value : '';
const prop = (props: unknown, name: string): string | undefined => list(props).map(object).find(item => item.name === name)?.value as string | undefined;
const REQUIREMENT_ID = /^[A-Z]{2,5}(?:\.\d+)+$/;
const PARAM_INSERT = /\{\{\s*insert:\s*param,\s*([^\s}]+)\s*\}\}/g;
const RELATIONSHIPS = new Set<string>(['equivalent-to', 'subset-of', 'superset-of', 'intersects-with']);
export function bsiKernelCatalog(raw: unknown, sourceUrl: string, sourceSha256: string): BsiKernelCatalog {
const catalog = object(object(raw).catalog);
const metadata = object(catalog.metadata);
const version = text(metadata.version);
if (!version || !Array.isArray(catalog.groups)) throw new Error('Expected an OSCAL Grundschutz++ catalog with a version.');
const practices: BsiKernelCatalog['practices'] = [];
const requirements: BsiRequirement[] = [];
const visitControls = (controls: unknown, practice: string, parentId?: string) => {
for (const control of list(controls).map(object)) {
const id = text(control.id);
if (control.class === BSI_KERNEL_CLASS) {
if (!REQUIREMENT_ID.test(id)) throw new Error(`Invalid Kernel requirement id: ${id || '(missing)'}`);
const parts = list(control.parts).map(object);
const statementPart = parts.find(part => part.name === 'statement');
const statement = text(statementPart?.prose);
if (!statement) throw new Error(`Kernel requirement ${id} has no statement.`);
const params = Object.fromEntries(list(control.params).map(object).map(param => {
const values = list(param.values).filter((value): value is string => typeof value === 'string');
return [text(param.id), values.length === 1 ? values[0] : text(param.label)];
}));
for (const part of parts) {
for (const match of text(part.prose).matchAll(PARAM_INSERT)) {
if (!params[match[1]]) throw new Error(`Kernel requirement ${id} inserts unknown parameter ${match[1]}.`);
}
}
const guidance = text(parts.find(part => part.name === 'guidance')?.prose);
requirements.push({
id, title: text(control.title), practice, ...(parentId ? { parentId } : {}), statement,
...optional('modalVerb', prop(statementPart?.props, 'modal_verb')),
...optional('guidance', guidance || undefined),
...optional('securityLevel', prop(control.props, 'sec_level')),
...optional('effortLevel', prop(control.props, 'effort_level')),
...(Object.keys(params).length ? { params } : {})
});
}
visitControls(control.controls, practice, id);
}
};
const visitGroups = (groups: unknown, practice?: string) => {
for (const group of list(groups).map(object)) {
const own = practice ?? text(group.id);
if (!practice) practices.push({ id: own, title: text(group.title) });
visitControls(group.controls, own);
visitGroups(group.groups, own);
}
};
visitGroups(catalog.groups);
if (!requirements.length) throw new Error('The catalog contains no Kernel requirements.');
const ids = requirements.map(item => item.id);
const repeated = ids.filter((id, index) => ids.indexOf(id) !== index);
if (repeated.length) throw new Error(`Repeated Kernel requirement ids: ${[...new Set(repeated)].join(', ')}`);
const used = new Set(requirements.map(item => item.practice));
return {
schemaVersion: 1, framework: 'bsi-grundschutz-plus-plus', catalogClass: BSI_KERNEL_CLASS, title: text(metadata.title), version,
sourceUrl, sourceSha256, license: BSI_NOTICE, practices: practices.filter(item => used.has(item.id)), requirements
};
}
export function bsiIsoCrosswalk(raw: unknown, sourceUrl: string, sourceSha256: string): BsiIsoCrosswalk {
const collection = object(object(raw)['mapping-collection']);
const metadata = object(collection.metadata);
const version = text(metadata.version);
if (!version || !Array.isArray(collection.mappings)) throw new Error('Expected an OSCAL mapping collection with a version.');
const kernel = collection.mappings.map(object).filter(mapping => /Kernel/i.test(text(object(mapping['target-resource']).href)));
if (kernel.length !== 1) throw new Error(`Expected exactly one mapping to the Kernel catalog, found ${kernel.length}.`);
const entries = list(kernel[0].maps).map(object).map((map): BsiCrosswalkEntry => {
const relationship = text(map.relationship);
const sources = list(map.sources).map(object).map(source => text(source['id-ref']));
const targets = list(map.targets).map(object).map(target => text(target['id-ref']));
if (!RELATIONSHIPS.has(relationship)) throw new Error(`Unknown mapping relationship: ${relationship || '(missing)'}`);
if (sources.length !== 1 || !/^[5-8]\.\d+$/.test(sources[0])) throw new Error(`Expected one ISO Annex A source control, found ${sources.join(', ') || 'none'}.`);
if (!targets.length || targets.some(target => !REQUIREMENT_ID.test(target))) throw new Error(`Invalid mapping targets for ISO ${sources[0]}.`);
return { uuid: text(map.uuid), isoControlId: sources[0], relationship: relationship as BsiCrosswalkRelationship, targets };
});
return {
schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: text(metadata.title), version,
sourceUrl, sourceSha256, license: BSI_NOTICE, targetCatalog: text(object(kernel[0]['target-resource']).href), entries
};
}
/** Statement or guidance prose with each parameter placeholder replaced by its published value or label. */
export function bsiRequirementText(prose: string, params: Record<string, string> | undefined): string {
return prose.replace(PARAM_INSERT, (placeholder, id: string) => params?.[id] ?? placeholder);
}
export interface BsiDerivedRequirement {
requirementId: string;
title: string;
practice: string;
/** Every accepted ISO control and relationship through which the requirement was reached. */
via: Array<{ isoControlId: string; relationship: BsiCrosswalkRelationship }>;
}
export interface BsiDerivation {
crosswalkVersion: string;
catalogVersion: string;
requirements: BsiDerivedRequirement[];
/** Mapping targets that the pinned Kernel catalog does not contain. They are reported, never reinterpreted. */
missingTargets: Array<{ isoControlId: string; requirementId: string }>;
}
/**
* Stage 1 (user decision 2026-10-08): the Kernel requirements the official BSI mapping assigns to accepted ISO
* controls. Derived only, never a decision of its own. Output is sorted, so the input order does not matter.
*/
export function deriveBsiRequirements(acceptedIsoControlIds: readonly string[], crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiDerivation {
const accepted = new Set(acceptedIsoControlIds);
const byId = new Map(catalog.requirements.map(item => [item.id, item]));
const derived = new Map<string, BsiDerivedRequirement>();
const missing = new Map<string, { isoControlId: string; requirementId: string }>();
for (const entry of crosswalk.entries) {
if (!accepted.has(entry.isoControlId)) continue;
for (const target of entry.targets) {
const requirement = byId.get(target);
if (!requirement) {
missing.set(`${entry.isoControlId}\u0000${target}`, { isoControlId: entry.isoControlId, requirementId: target });
continue;
}
const item = derived.get(target) ?? { requirementId: target, title: requirement.title, practice: requirement.practice, via: [] };
if (!item.via.some(via => via.isoControlId === entry.isoControlId && via.relationship === entry.relationship)) {
item.via.push({ isoControlId: entry.isoControlId, relationship: entry.relationship });
}
derived.set(target, item);
}
}
const order = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true });
return {
crosswalkVersion: crosswalk.version,
catalogVersion: catalog.version,
requirements: [...derived.values()]
.map(item => ({ ...item, via: item.via.sort((a, b) => order(a.isoControlId, b.isoControlId) || a.relationship.localeCompare(b.relationship)) }))
.sort((a, b) => order(a.requirementId, b.requirementId)),
missingTargets: [...missing.values()].sort((a, b) => order(a.isoControlId, b.isoControlId) || order(a.requirementId, b.requirementId))
};
}
export interface BsiOverviewRequirement extends BsiDerivedRequirement {
/** Statement with its parameters filled in, for display. */
statement: string;
modalVerb?: string;
}
/** The derived BSI requirements a response carries, with the catalog provenance and attribution the UI shows. */
export interface BsiOverview {
catalogVersion: string;
crosswalkVersion: string;
license: string;
/** Every practice of the catalog in catalog order, so the UI can order and title them without the catalog. */
practices: Array<{ id: string; title: string }>;
requirements: BsiOverviewRequirement[];
missingTargets: BsiDerivation['missingTargets'];
}
/** Stage 1 overview for accepted ISO controls; undefined when nothing is derived, so ISO-only responses stay unchanged. */
export function bsiOverview(acceptedIsoControlIds: readonly string[], crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiOverview | undefined {
const derivation = deriveBsiRequirements(acceptedIsoControlIds, crosswalk, catalog);
if (!derivation.requirements.length && !derivation.missingTargets.length) return undefined;
const byId = new Map(catalog.requirements.map(item => [item.id, item]));
return {
catalogVersion: catalog.version,
crosswalkVersion: crosswalk.version,
license: catalog.license,
practices: catalog.practices,
requirements: derivation.requirements.map(item => {
const requirement = byId.get(item.requirementId)!;
return { ...item, statement: bsiRequirementText(requirement.statement, requirement.params),
...(requirement.modalVerb ? { modalVerb: requirement.modalVerb } : {}) };
}),
missingTargets: derivation.missingTargets
};
}
/** Every Kernel requirement by practice in catalog order with its title (CC BY-SA 4.0), and whether the BSI mapping can reach it from any ISO control. */
export interface BsiFootprint {
catalogVersion: string;
crosswalkVersion: string;
practices: Array<{ id: string; title: string; requirements: Array<{ id: string; title: string; reachable: boolean }> }>;
}
/** The Client Info footprint grid; independent of decisions, so it exists before anything is derived. */
export function bsiFootprint(crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiFootprint {
const reachable = new Set(crosswalk.entries.flatMap(entry => entry.targets));
return {
catalogVersion: catalog.version,
crosswalkVersion: crosswalk.version,
practices: catalog.practices.map(practice => ({ ...practice, requirements: catalog.requirements
.filter(requirement => requirement.practice === practice.id)
.map(requirement => ({ id: requirement.id, title: requirement.title, reachable: reachable.has(requirement.id) })) }))
};
}
/**
* The findings of each derived requirement: every finding of an accepted ISO control the requirement was
* reached through, once per Result DB row, like the ISO theme counts.
*/
export function bsiRequirementFindings<F extends { resultProjectionId: string }>(
controls: ReadonlyArray<{ controlId: string; findings: readonly F[] }>, overview: BsiOverview | undefined
): Map<string, F[]> {
const byControl = new Map(controls.map(control => [control.controlId, control.findings]));
return new Map((overview?.requirements ?? []).map(requirement => {
const findings = new Map<string, F>();
for (const via of requirement.via) {
for (const finding of byControl.get(via.isoControlId) ?? []) if (!findings.has(finding.resultProjectionId)) findings.set(finding.resultProjectionId, finding);
}
return [requirement.requirementId, [...findings.values()]];
}));
}
function optional<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
return value ? { [key]: value } as Record<K, string> : {};
}