import { createHash, randomUUID } from 'node:crypto'; import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute } from 'node:path'; import { BSI_KERNEL_CLASS, bsiIsoCrosswalk, bsiKernelCatalog, type BsiIsoCrosswalk, type BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz'; import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management'; import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots'; // Fixed official sources (security model: outbound HTTPS to raw.githubusercontent.com only, user decision 2026-10-08). // BSI publishes continuously without releases, so the update reads `main` and records the SHA-256 of what it read. const BSI_BASE = 'https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/main/control_layer'; export const BSI_CATALOG_SOURCE_URL = `${BSI_BASE}/Grundschutz++/Grundschutz++-resolved_catalog.json`; export const BSI_CROSSWALK_SOURCE_URL = `${BSI_BASE}/Mappings/ISO-27001-zu-GSpp/ISO27001-AnnexA-to-GS++-mapping_collection.json`; const MAXIMUM_SOURCE_BYTES = 32 * 1024 * 1024; const MAXIMUM_STORED_BYTES = 8 * 1024 * 1024; export class BsiCatalogError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'BsiCatalogError'; } } interface StoredBsiCatalogs extends BsiCatalogs { version: 1; updatedAt: string; } /** * The active BSI Kernel catalog and ISO 27001 mapping of the Compliance Engine. The pinned snapshots stay active * until `Update from BSI` stores a newer official state in the engine's private folder; derived requirements * follow the active state at once, because they are never stored. */ export class BsiCatalogService { private refreshInProgress?: Promise; private cached?: Promise<{ catalogs: BsiCatalogs; updatedAt?: string }>; constructor( private readonly storagePath: string, private readonly fetcher: typeof fetch = globalThis.fetch.bind(globalThis), private readonly now: () => Date = () => new Date() ) { if (!storagePath || !isAbsolute(storagePath)) throw new Error('An absolute BSI catalog storage path is required.'); } /** The active catalogs for the derivation. */ async catalogs(): Promise { return (await this.active()).catalogs; } async current(): Promise { const { catalogs, updatedAt } = await this.active(); return state(catalogs, updatedAt); } refresh(): Promise { if (this.refreshInProgress) return this.refreshInProgress; this.refreshInProgress = this.performRefresh().finally(() => { this.refreshInProgress = undefined; }); return this.refreshInProgress; } /** Returns to the pinned snapshots. */ async reset(): Promise { await unlink(this.storagePath).catch(error => { if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error; }); this.cached = undefined; return this.current(); } private active(): Promise<{ catalogs: BsiCatalogs; updatedAt?: string }> { this.cached ??= this.read().catch(error => { this.cached = undefined; throw error; }); return this.cached; } private async read(): Promise<{ catalogs: BsiCatalogs; updatedAt?: string }> { let text: string; try { const information = await stat(this.storagePath); if (!information.isFile() || information.size > MAXIMUM_STORED_BYTES) throw new BsiCatalogError('bsi_storage_invalid', 'The stored BSI catalog is not a regular bounded file.'); text = await readFile(this.storagePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return { catalogs: bundledBsiCatalogs }; throw error; } const stored = JSON.parse(text) as Partial; if (stored.version !== 1 || typeof stored.updatedAt !== 'string' || !validCatalog(stored.catalog) || !validCrosswalk(stored.crosswalk)) { throw new BsiCatalogError('bsi_storage_invalid', 'The stored BSI catalog is invalid. Use the bundled catalog or update from BSI again.'); } // A stored state older than the bundled snapshot is superseded by it. if (stored.catalog.version < bundledBsiCatalogs.catalog.version) return { catalogs: bundledBsiCatalogs }; return { catalogs: { catalog: stored.catalog, crosswalk: stored.crosswalk }, updatedAt: stored.updatedAt }; } private async performRefresh(): Promise { const [catalogSource, crosswalkSource] = await Promise.all([download(this.fetcher, BSI_CATALOG_SOURCE_URL), download(this.fetcher, BSI_CROSSWALK_SOURCE_URL)]); let latest: BsiCatalogs; try { latest = { catalog: bsiKernelCatalog(JSON.parse(catalogSource.toString('utf8')), BSI_CATALOG_SOURCE_URL, sha256(catalogSource)), crosswalk: bsiIsoCrosswalk(JSON.parse(crosswalkSource.toString('utf8')), BSI_CROSSWALK_SOURCE_URL, sha256(crosswalkSource)) }; } catch (error) { throw new BsiCatalogError('bsi_source_invalid', `The BSI files are not a usable Grundschutz++ catalog and ISO mapping: ${error instanceof Error ? error.message : 'invalid content'}`); } const current = await this.active(); const unchanged = latest.catalog.sourceSha256 === current.catalogs.catalog.sourceSha256 && latest.crosswalk.sourceSha256 === current.catalogs.crosswalk.sourceSha256; // Never step back behind the active catalog. if (unchanged || latest.catalog.version < current.catalogs.catalog.version) return { ...state(current.catalogs, current.updatedAt), updated: false }; const directory = dirname(this.storagePath); await mkdir(directory, { recursive: true, mode: 0o700 }); const directoryInfo = await lstat(directory); if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) throw new BsiCatalogError('bsi_storage_unavailable', 'The private BSI catalog storage is not available.'); const stored: StoredBsiCatalogs = { version: 1, updatedAt: this.now().toISOString(), ...latest }; const temporaryPath = `${this.storagePath}.${randomUUID()}.tmp`; try { await writeFile(temporaryPath, JSON.stringify(stored), { mode: 0o600 }); await rename(temporaryPath, this.storagePath); } finally { await unlink(temporaryPath).catch(error => { if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error; }); } this.cached = Promise.resolve({ catalogs: latest, updatedAt: stored.updatedAt }); return { ...state(latest, stored.updatedAt), updated: true }; } } const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); async function download(fetcher: typeof fetch, url: string): Promise { let response: Response; try { response = await fetcher(url, { headers: { Accept: 'application/json' }, cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(120_000) }); } catch { throw new BsiCatalogError('bsi_unreachable', 'BSI could not be reached. Please try again later.'); } if (!response.ok || !response.body) throw new BsiCatalogError('bsi_unavailable', `BSI returned no usable file (HTTP ${response.status}).`); const declaredSize = Number(response.headers.get('content-length')); if (Number.isFinite(declaredSize) && declaredSize > MAXIMUM_SOURCE_BYTES) { await response.body.cancel(); throw new BsiCatalogError('bsi_source_too_large', 'A BSI file exceeds the size limit.'); } const chunks: Buffer[] = []; const reader = response.body.getReader(); let size = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAXIMUM_SOURCE_BYTES) throw new BsiCatalogError('bsi_source_too_large', 'A BSI file exceeds the size limit.'); chunks.push(Buffer.from(value)); } } catch (error) { await reader.cancel().catch(() => undefined); if (error instanceof BsiCatalogError) throw error; throw new BsiCatalogError('bsi_download_failed', 'The BSI download was interrupted.'); } return Buffer.concat(chunks, size); } function validCatalog(value: unknown): value is BsiKernelCatalog { const catalog = value as BsiKernelCatalog | undefined; return catalog?.schemaVersion === 1 && catalog.framework === 'bsi-grundschutz-plus-plus' && catalog.catalogClass === BSI_KERNEL_CLASS && typeof catalog.version === 'string' && /^[0-9a-f]{64}$/.test(catalog.sourceSha256 ?? '') && Array.isArray(catalog.practices) && Array.isArray(catalog.requirements) && catalog.requirements.length > 0; } function validCrosswalk(value: unknown): value is BsiIsoCrosswalk { const crosswalk = value as BsiIsoCrosswalk | undefined; return crosswalk?.schemaVersion === 1 && crosswalk.framework === 'bsi-iso27001-annex-a-to-grundschutz-plus-plus' && typeof crosswalk.version === 'string' && /^[0-9a-f]{64}$/.test(crosswalk.sourceSha256 ?? '') && Array.isArray(crosswalk.entries); } function state({ catalog, crosswalk }: BsiCatalogs, updatedAt: string | undefined): BsiCatalogState { const ids = new Set(catalog.requirements.map(item => item.id)); return { version: 1, origin: updatedAt ? 'bsi' : 'bundled', ...(updatedAt ? { updatedAt } : {}), catalogVersion: catalog.version, crosswalkVersion: crosswalk.version, requirementCount: catalog.requirements.length, crosswalkEntryCount: crosswalk.entries.length, catalogSha256: catalog.sourceSha256, crosswalkSha256: crosswalk.sourceSha256, missingTargets: [...new Set(crosswalk.entries.flatMap(entry => entry.targets.filter(target => !ids.has(target))))].sort() }; }