285 lines
14 KiB
TypeScript
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> : {};
|
|
}
|