Assessor_2.0/server/compliance/bsi-catalog-service.ts

191 lines
9.5 KiB
TypeScript

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<BsiCatalogState & { updated: boolean }>;
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<BsiCatalogs> {
return (await this.active()).catalogs;
}
async current(): Promise<BsiCatalogState> {
const { catalogs, updatedAt } = await this.active();
return state(catalogs, updatedAt);
}
refresh(): Promise<BsiCatalogState & { updated: boolean }> {
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<BsiCatalogState> {
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<StoredBsiCatalogs>;
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<BsiCatalogState & { updated: boolean }> {
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<Buffer> {
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()
};
}